🐍 Python Course · Stage 2 · Lesson 21 of 89 · Error Handling
Course Overview →

🔵 Read Beginner then Intermediate tabs. Visit Expert when ready.

← Data Model Exception Hierarchy →
thecodex.expert · The Codex Family of Knowledge
Python

Error Handling in Python

Exceptions are events, not catastrophes. Python's error handling model makes catching, raising, and recovering clean and explicit.

Python 3.13 docs.python.org Last verified:
Canonical Definition

Python error handling uses exceptions — objects that inherit from BaseException, raised with raise and caught with try/except — with finally for guaranteed cleanup and context managers (the with statement) for resource management.

🟩 Beginner

Writing code that doesn't crash

What you'll learn: How Python's exception system works, how to catch errors with try/except, and how to raise your own exceptions. After this tab your programs will handle unexpected situations gracefully instead of printing a traceback and dying.

How to read this tab: For each code example, look at what exception type is being caught and why. Think about: what could go wrong in this code? That's the question error handling answers.

⏱ 40 min 📄 3 sections 🔶 Prerequisite: Functions & Scope
The analogy

Error handling is like a safety net under a tightrope. You hope you don't fall, but you have a plan for when you do. In Python, errors are not crashes waiting to happen — they're events your program can catch, handle, and recover from.

💡

The hierarchy matters for catching. All exceptions inherit from BaseException. Most inherit from Exception. This means except Exception: catches almost everything (but not KeyboardInterrupt or SystemExit, which inherit directly from BaseException — deliberately). Always catch the most specific exception you can.

Python's exception hierarchy

In Python, all exceptions are objects that inherit from BaseException. Most exceptions you'll deal with inherit from Exception (which inherits from BaseException). System-exiting exceptions (SystemExit, KeyboardInterrupt, GeneratorExit) inherit directly from BaseException — they should almost never be caught.

ExceptionWhen it occurs
ValueErrorRight type, wrong value: int("hello")
TypeErrorWrong type: "3" + 5
NameErrorUndefined name: print(x) before assigning x
IndexErrorOut-of-bounds list access: lst[100]
KeyErrorMissing dict key: d["missing"]
AttributeErrorNo such attribute: None.name
FileNotFoundErrorFile does not exist
ZeroDivisionError10 / 0
StopIterationIterator exhausted (normal — do not catch)
ImportErrorModule not found

The four clauses and when each runs: try — the risky code. except — only if an exception was raised. else — only if NO exception was raised. finally — always, exception or not. The else clause is often forgotten but valuable: it's where you put code that should only run on success.

try / except / else / finally

Pythontry_except.py
# Basic try/except
try:
    result = 10 / 0
except ZeroDivisionError:
    print("Cannot divide by zero")

# Multiple except clauses
def parse_number(text):
    try:
        return int(text)
    except ValueError:
        try:
            return float(text)
        except ValueError:
            return None

# Catch multiple exceptions in one clause
try:
    data = int(input("Enter a number: "))
except (ValueError, EOFError) as e:
    print(f"Input error: {e}")

# else: runs only if no exception was raised
# finally: ALWAYS runs — cleanup code
def read_file(path):
    f = None
    try:
        f = open(path)
        return f.read()
    except FileNotFoundError:
        return None
    else:
        print("File read successfully")   # only if no exception
    finally:
        if f: f.close()   # ALWAYS closes the file

# Better: use context manager
def read_file_clean(path):
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError:
        return None
📎

Raise exceptions early and informatively. When your function receives bad input, don't silently ignore it or return None. Raise a specific exception with a clear message: raise ValueError(f"Score must be 0-100, got {score}"). The person calling your function will thank you — they get a clear error instead of a mysterious failure later.

Raising exceptions

Pythonraising_exceptions.py
# Raise a built-in exception
def divide(a, b):
    if b == 0:
        raise ZeroDivisionError("b cannot be zero")
    return a / b

# Custom exception — inherit from Exception
class InsufficientFundsError(Exception):
    def __init__(self, requested, available):
        self.requested = requested
        self.available = available
        super().__init__(
            f"Requested ₹{requested:,} but only ₹{available:,} available"
        )

def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(amount, balance)
    return balance - amount

try:
    withdraw(1000, 2000)
except InsufficientFundsError as e:
    print(e)                   # Requested ₹2,000 but only ₹1,000 available
    print(e.requested)         # 2000
    print(e.available)         # 1000

# Re-raise — preserve original exception
try:
    result = 1 / 0
except ZeroDivisionError:
    print("Handling...")
    raise   # re-raises the same exception

# Exception chaining (Python 3)
try:
    1 / 0
except ZeroDivisionError as original:
    raise ValueError("Bad input") from original  # chains context

✅ Beginner tab complete — check your understanding

  • I can wrap risky code in try/except and handle the error gracefully
  • I know the 5 most common exception types: TypeError, ValueError, KeyError, IndexError, FileNotFoundError
  • I can use the else clause (runs if no exception) and finally clause (always runs)
  • I can raise my own exception with raise ValueError("message")

Continue to File I/O → — where error handling becomes essential (files that don't exist, wrong permissions, etc.)

🔵 Intermediate

Context managers, custom exceptions, and exception groups

What you'll learn: The with statement (context managers) — the Python way to ensure cleanup always happens. How to write custom exception classes for your own code. Exception groups (Python 3.11+) for handling multiple simultaneous errors.

How to read this tab: The context manager section is essential for professional Python. After reading it, understand why with open(...) as f: is the only correct way to open files.

⏱ 40 min 📄 2 sections 🔶 Prerequisite: Beginner tab

The with statement is essential Python. It guarantees that cleanup happens even if an exception occurs. Files, database connections, network sockets, locks — anything that needs to be closed or released should use a context manager. with open() is just the most common example. Any object with __enter__ and __exit__ methods works.

Context managers and the with statement

A context manager implements __enter__ (called on entry, can return a value bound by as) and __exit__ (called on exit, receives exception info if one occurred). If __exit__ returns truthy, the exception is suppressed. This is how with open(...) guarantees the file closes even if an exception is raised inside the block.

Pythoncontext_managers.py
# Custom context manager using a class
class Timer:
    def __enter__(self):
        import time
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        import time
        self.elapsed = time.perf_counter() - self.start
        print(f"Elapsed: {self.elapsed:.4f}s")
        return False   # do not suppress exceptions

with Timer() as t:
    sum(range(1_000_000))
# Elapsed: 0.0432s

# contextlib.contextmanager — generator-based context manager
from contextlib import contextmanager, suppress

@contextmanager
def managed_resource():
    print("Acquiring resource")
    resource = {"status": "active"}
    try:
        yield resource      # body of the with block runs here
    finally:
        resource["status"] = "closed"
        print("Released resource")

with managed_resource() as r:
    print(r["status"])   # active
# Released resource

# suppress — silently ignore specific exceptions
with suppress(FileNotFoundError):
    open("nonexistent.txt")
# No error raised
📎

For handling multiple simultaneous exceptions. Exception groups (PEP 654) let you raise and handle multiple unrelated exceptions that occurred at the same time — common in concurrent code where several tasks may fail independently. The except* syntax is specifically for this. If you're not using Python 3.11+ or concurrent code, skip this for now.

Exception groups (Python 3.11+)

PEP 654 (Python 3.11) introduced ExceptionGroup for handling multiple concurrent exceptions — essential for async code where many tasks may fail simultaneously. The except* syntax handles exception groups.

Pythonexception_groups.py
# Python 3.11+
import asyncio

async def failing_task(n):
    raise ValueError(f"Task {n} failed")

async def main():
    async with asyncio.TaskGroup() as tg:
        tg.create_task(failing_task(1))
        tg.create_task(failing_task(2))
    # If multiple tasks fail, raises ExceptionGroup

try:
    asyncio.run(main())
except* ValueError as eg:
    for exc in eg.exceptions:
        print(exc)   # Task 1 failed, Task 2 failed

# Create and handle manually
try:
    raise ExceptionGroup("multiple errors", [
        ValueError("bad value"),
        TypeError("bad type"),
    ])
except* ValueError as eg:
    print(f"ValueErrors: {eg.exceptions}")
except* TypeError as eg:
    print(f"TypeErrors: {eg.exceptions}")
Commonly confused
Never use a bare except:. except: without a type catches everything — including KeyboardInterrupt, SystemExit, and programming errors. Use except Exception: at the minimum, or better, name specific exceptions. Catching everything silences bugs.
Don't use exceptions for flow control. In Python, exceptions are for errors and genuinely exceptional conditions. The EAFP style ("Easier to Ask Forgiveness than Permission") — try it and handle the exception — is idiomatic. But using StopIteration outside a generator, or raising exceptions in normal code paths, is an anti-pattern that harms performance and readability.
finally runs even if there is no exception. finally also runs if the try block has a return, break, or continue. The finally block runs before the enclosing scope's return — and if finally itself has a return, it overrides the try block's return value.

✅ Intermediate tab complete — check your understanding

  • I understand what __enter__ and __exit__ do and why the with statement calls them
  • I can write a custom exception class that inherits from Exception
  • I know when to catch a specific exception vs catching Exception base class
  • I know never to use bare except: (always specify an exception type)

Continue to File I/O →

🔴 Expert

CPython's zero-cost exceptions and PEP 3151

What you'll learn: How CPython implements exceptions as a zero-cost-unless-raised mechanism. Exception chaining (raise X from Y). PEP 3151's restructuring of the OS error hierarchy. The performance implications of exception-driven control flow.

How to read this tab: Read after Stage 2. Relevant when optimising hot code paths or understanding why exceptions should not be used for normal control flow.

⏱ 25 min 📄 2 sections 🔶 Prerequisite: After Stage 2

Exception handling and the CPython implementation

In CPython, exception handling uses a zero-cost model at the bytecode level (since Python 3.11, PEP 654 and related changes): when no exception is raised, try/except blocks have essentially zero overhead — the interpreter does not set up any data structure on the hot path. When an exception is raised, CPython uses a combination of the exception table (generated by the compiler — a mapping from bytecode range to handler) and the exception value stored in the thread state. This is documented in CPython's ceval.c and the compiler module.

PEP 3151 — Reworking the OS and IO exception hierarchy

Python 3.3 (PEP 3151) unified the OS error hierarchy. Before 3.3, you had to check errno to distinguish ENOENT from EACCES. After 3.3, there are specific exception classes: FileNotFoundError, PermissionError, IsADirectoryError, TimeoutError, etc. — all subclasses of OSError. This is why you can write except FileNotFoundError instead of except OSError as e: if e.errno == errno.ENOENT:.

✅ Expert tab complete

  • I know that Python's exceptions have zero overhead when NOT raised
  • I can use raise X from Y to chain exceptions and preserve context
  • I know the difference between __cause__ (explicit chaining) and __context__ (implicit chaining)

Continue to File I/O →

Sources

1
Python Language Reference §8 — Compound statements (try). docs.python.org/3/reference/compound_stmts.html#try.
2
Python Built-in Exceptions. docs.python.org/3/library/exceptions.html.
3
PEP 343 — The "with" Statement. peps.python.org/pep-0343/.
4
PEP 654 — Exception Groups and except*. peps.python.org/pep-0654/.
5
PEP 3151 — Reworking the OS and IO exception hierarchy. peps.python.org/pep-3151/.
Source confidence: High Last verified: Primary source: Python Language Reference · docs.python.org/3/reference/