Polyglot
Languages Python error handling
Python § error-handling

Error handling

Python uses exceptions as the principal error-propagation mechanism. The exception hierarchy is rooted at BaseException; user-facing exceptions derive from Exception. The try/except/else/finally admits structured handling; raise produces exceptions; with admits resource management with exception-safe cleanup. Python’s EAFP style (“easier to ask forgiveness than permission”) favours attempting an operation and catching the failure over checking preconditions; the conventional Python idiom uses exceptions extensively for ordinary control flow that other languages might handle with return codes or option types.

This page covers the exception model, the principal exception types, the try statement variants, context managers, and the conventional discipline.

The exception model

An exception is raised with raise, propagates up the call stack, and is caught by an enclosing try/except:

def divide(a, b):
    if b == 0:
        raise ZeroDivisionError("denominator must not be zero")
    return a / b

try:
    result = divide(10, 0)
except ZeroDivisionError as e:
    print(f"error: {e}")

The principal operations:

  • raise SomeException(args) — raise a new exception.
  • raise (alone, in an except) — re-raise the current exception.
  • raise NewException from original — chained re-raise; preserves the cause.
  • try:except:else:finally: — the structured form.

The exception hierarchy

The standard library’s exception hierarchy:

BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
    ├── ArithmeticError
    │   ├── ZeroDivisionError
    │   ├── OverflowError
    │   └── FloatingPointError
    ├── AttributeError
    ├── EOFError
    ├── ImportError
    │   └── ModuleNotFoundError
    ├── LookupError
    │   ├── IndexError
    │   └── KeyError
    ├── MemoryError
    ├── NameError
    │   └── UnboundLocalError
    ├── OSError
    │   ├── FileNotFoundError
    │   ├── PermissionError
    │   ├── TimeoutError
    │   └── (many more)
    ├── ReferenceError
    ├── RuntimeError
    │   ├── NotImplementedError
    │   └── RecursionError
    ├── StopIteration
    ├── StopAsyncIteration
    ├── SyntaxError
    ├── SystemError
    ├── TypeError
    ├── ValueError
    │   └── UnicodeError
    └── Warning

The convention:

  • BaseException is the base of everything. Catching it intercepts KeyboardInterrupt and SystemExit, which is rarely desirable.
  • Exception is the conventional base for catching “any problem”. Most user code catches subclasses of Exception.
  • SystemExit and KeyboardInterrupt derive from BaseException rather than Exception so that except Exception: does not swallow them.

try/except

The basic form:

try:
    risky_operation()
except SomeError as e:
    handle(e)

Multiple handlers:

try:
    risky_operation()
except FileNotFoundError:
    handle_missing()
except PermissionError as e:
    handle_permission(e)
except OSError as e:
    handle_other_os(e)

The handlers are tried in order; the first matching one runs. The as e binds the exception to a name (for inspection); without it, the exception is caught but not bound.

A single handler may match multiple types:

try:
    operation()
except (FileNotFoundError, PermissionError) as e:
    handle(e)

The tuple form admits “any of these”.

else and finally

The full try admits two additional clauses:

try:
    f = open(path)
except FileNotFoundError:
    print("missing")
else:
    # Runs only if no exception was raised
    print("opened")
    process(f)
finally:
    # Runs always (success or exception)
    f.close()

The semantics:

  • else — runs if the try block completes without an exception.
  • finally — runs unconditionally, even on exception, return, or break/continue.

The conventional uses:

  • else for code that depends on the try succeeding (avoids putting it in the try itself, which would catch its exceptions too).
  • finally for cleanup (closing files, releasing locks, etc.).

For most cleanup cases, with (treated below) is the conventional contemporary form.

raise

Raising an exception:

raise ValueError("invalid input")
raise ValueError                          # exception class; instantiates with no args
raise ValueError("invalid", "input")      # multiple args

Re-raising:

try:
    operation()
except ValueError:
    log("got a ValueError")
    raise                                  # re-raise the same exception

Bare raise re-raises the current exception (only valid inside an except block); preserves the original traceback.

Chained re-raise:

try:
    parse(data)
except ValueError as e:
    raise RuntimeError("could not parse") from e

The from e admits exception chaining — the new exception’s __cause__ is the original. Tracebacks show both:

ValueError: invalid format
The above exception was the direct cause of the following:
RuntimeError: could not parse

To suppress the chain (when the original is irrelevant):

raise RuntimeError("could not parse") from None

The from None admits a clean traceback.

Custom exceptions

User-defined exceptions inherit from Exception (or a more specific base):

class ParseError(Exception):
    """Raised when input cannot be parsed."""
    pass

class ParseError(Exception):
    def __init__(self, message: str, line: int):
        super().__init__(message)
        self.line = line

raise ParseError("bad syntax", line=42)

The conventional discipline:

  • Inherit from a meaningful base (ValueError for value errors, RuntimeError for runtime conditions, Exception for ad-hoc).
  • Provide a docstring explaining when the exception is raised.
  • Add attributes (line, column, path) when contextual information matters.

For application-level errors, a hierarchy is conventional:

class AppError(Exception):
    """Base for all application errors."""

class ConfigError(AppError):
    """Configuration is invalid."""

class DatabaseError(AppError):
    """Database operation failed."""

# A caller can catch all application errors:
except AppError:
    ...

# Or be more specific:
except ConfigError:
    ...

The pattern admits both broad and narrow catching.

with and context managers

The with statement admits resource management with exception-safe cleanup:

with open(path) as f:
    contents = f.read()
# f is closed automatically, even on exception

The with statement uses the context-manager protocol: an object with __enter__ and __exit__. Treated in Duck typing and protocols.

For resources that should be cleaned up, the conventional Python form is with:

with open(path) as f, open(other_path) as g:    # multiple resources
    process(f, g)

with lock:
    do_critical_section()

with transaction(db):
    db.execute(...)

The contextlib.suppress admits silently ignoring an exception:

from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove("temp.txt")
# no exception raised if the file is missing

The suppress form is the conventional Python idiom for “remove this if it exists”.

EAFP vs LBYL

Python’s idiomatic style is EAFP — “easier to ask forgiveness than permission”. The pattern: try the operation; catch the failure.

# EAFP:
try:
    value = mapping[key]
except KeyError:
    value = default

The contrast is LBYL — “look before you leap”:

# LBYL:
if key in mapping:
    value = mapping[key]
else:
    value = default

The conventional Python preference is EAFP for several reasons:

  • Race conditions: between the check and the use, another thread (or another process modifying the filesystem) may change the state.
  • Performance: for the common case (the operation succeeds), EAFP has no overhead beyond the operation itself.
  • Idiomatic: Python’s exception machinery is designed for this style; exceptions are not “exceptional” in Python’s idiom.

For dict access specifically, .get(key, default) is the conventional one-liner:

value = mapping.get(key, default)

The .get is neither EAFP nor LBYL; it’s a built-in API for the common case.

For attribute access:

# EAFP:
try:
    name = obj.name
except AttributeError:
    name = "anonymous"

# Or with getattr:
name = getattr(obj, "name", "anonymous")

The getattr(obj, "name", default) is the conventional Python form.

assert

The assert statement checks an invariant:

def divide(a: int, b: int) -> float:
    assert b != 0, "denominator must not be zero"
    return a / b

assert cond, message raises AssertionError(message) if cond is false.

A critical caveat: assert is disabled when Python runs with -O (optimised mode). The conventional discipline:

  • Use assert for invariants — conditions that should always hold by construction.
  • Use raise for runtime checks — conditions that depend on input or external state.
# Invariant: after this block, list is non-empty:
result = compute(...)
assert len(result) > 0          # for the developer; not for production input

# Runtime check: input may be empty:
if not input_list:
    raise ValueError("input must be non-empty")

The runtime check should never be disabled by -O.

Common patterns

Try-except-else

try:
    response = http.get(url)
except RequestException as e:
    log_error(e)
    return None
else:
    return response.json()

The else admits the success path without including it in the try (which would catch any of its exceptions too).

Resource cleanup

with open(path, "w") as f:
    f.write(data)
# file is closed and flushed

The with is the conventional Python idiom for resource cleanup; preferable to try/finally for the common case.

Multiple resources

with open(in_path) as src, open(out_path, "w") as dst:
    for line in src:
        dst.write(transform(line))

The form admits opening multiple files with one with statement.

Custom context manager

from contextlib import contextmanager

@contextmanager
def timer(name):
    import time
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f"{name} took {elapsed:.3f}s")

with timer("computation"):
    do_work()
# computation took 0.123s

The @contextmanager admits writing context managers as generator functions; the yield is the entry point.

Suppressing specific exceptions

from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove(temp_file)

The suppress form is shorter than try/except: pass.

Exception groups (Python 3.11+)

For handling multiple unrelated exceptions:

# Python 3.11+:
try:
    do_work()
except* ValueError as eg:
    for e in eg.exceptions:
        log_value_error(e)
except* TypeError as eg:
    for e in eg.exceptions:
        log_type_error(e)

The except* admits handling ExceptionGroups — typically produced by asyncio.TaskGroup and similar concurrent contexts. The mechanism is rare in routine code.

Validation

def validate_input(data):
    if not isinstance(data, dict):
        raise TypeError("data must be a dict")
    if "name" not in data:
        raise KeyError("data must include 'name'")
    if not data["name"]:
        raise ValueError("name must not be empty")

The pattern admits early-return with specific error messages.

Result-style returns

For cases where exceptions feel heavy, return a result type:

from dataclasses import dataclass

@dataclass
class Success:
    value: object

@dataclass
class Failure:
    error: str

def parse(data) -> Success | Failure:
    try:
        return Success(value=int(data))
    except ValueError as e:
        return Failure(error=str(e))

The pattern is rare in Python (which conventionally uses exceptions); it admits cases where the caller wants explicit handling without try/except.

Common defects

DefectDescription
Catching Exception indiscriminatelyCatches everything but KeyboardInterrupt and similar. Catch the specific types.
Catching BaseExceptionCatches KeyboardInterrupt too — the user can’t interrupt the program.
Empty except: passSwallows all errors silently. Either log, re-raise, or do not catch.
raise e instead of raiseraise e resets the traceback. Bare raise preserves it.
Catching the wrong typeexcept IOError: was renamed to OSError in Python 3; the old name is an alias.
Forgetting with for resourcesFiles, locks, sockets — leak resources without explicit cleanup.
try/finally instead of withThe with form is conventional and clearer for the common case.

A note on the discipline

The conventional contemporary Python error-handling advice:

  • Use exceptions for exceptional cases — but Python’s idiom is broader than other languages’; ordinary control flow uses exceptions too.
  • Catch specific exception types; rarely catch Exception and never catch BaseException.
  • Use with for resource cleanup.
  • Use chained re-raise (raise ... from e) to preserve the cause.
  • Use custom exception hierarchies for application-level errors.
  • Use assert for invariants, raise for runtime checks.
  • Use EAFP for ordinary access patterns; LBYL only when the check has substantive value beyond the operation.

The combination — explicit exception types, structured handling, with-based cleanup, and the EAFP idiom — is the substance of Python’s error-handling discipline.