Exceptions are often described as errors, but Python uses them for broader non-local control flow. Iteration ends with StopIteration. Context managers may suppress exceptions. Generators can be closed by injecting GeneratorExit. Cancellation libraries use exception-like signals. Ordinary application failures travel through the same mechanism.

The simple model is "raise searches for an except." The useful model includes more: Python creates or normalizes an exception, attaches traceback information as it propagates, matches handlers by class, unwinds nested cleanup regions, tracks context and explicit causes, and eventually transfers control or reports an unhandled failure.

Version boundary. Raising, matching, try statements, chaining, tracebacks, and exception groups are Python semantics. CPython 3.14's thread exception state, zero-cost exception tables, frame unwinding, and opcodes are implementation details. Exception groups arrived in Python 3.11, and finally control-flow restrictions changed in Python 3.14. All experiments were run on CPython 3.14.7.

Experiment 1: handlers match classes

An except clause performs subclass matching, not string or exact-type matching.

Pyodide / WebAssembly
class ConfigError(Exception):
    pass


class MissingConfig(ConfigError):
    pass


try:
    raise MissingConfig("database.url")
except ConfigError as error:
    print("[error] caught type:", type(error).__name__)
    print("[state] exception args:", error.args)

MissingConfig is caught because it subclasses ConfigError. The instance's args stores constructor arguments by the base implementation; custom exception attributes can provide a clearer machine-readable contract.

Catch at the abstraction level you can handle. A configuration loader may translate parsing and missing-key details into ConfigError; a top-level command can report that domain failure. except Exception deep inside a library usually catches programming bugs it cannot repair. Bare except is broader still and catches BaseException subclasses such as KeyboardInterrupt and SystemExit.

Experiment 2: propagation unwinds frames

Code after the failing operation does not run unless a handler resumes execution outside the abandoned suite.

Pyodide / WebAssembly
events = []


def inner():
    events.append("inner start")
    raise ValueError("bad value")


def outer():
    events.append("outer start")
    inner()
    events.append("outer end")


try:
    outer()
except ValueError:
    events.append("handled")

print("[event] unwind order:", events)

The missing outer end is control flow, not process termination. Python searches outward through active protected regions and frames until it finds a matching handler. If none exists, the exception reaches the thread or task boundary, where runtime machinery reports or stores it.

Exceptions therefore belong in API design. Document which domain failures callers can act on. Avoid using exceptions for expected high-volume branch choices when a return value is clearer, but do not replace every exceptional result with an ambiguous sentinel merely to avoid raising.

Experiment 3: finally runs on every exit path

Cleanup executes for normal return and exceptional propagation.

Pyodide / WebAssembly
events = []


def operation(fail):
    try:
        events.append("work")
        if fail:
            raise RuntimeError("boom")
        return "ok"
    finally:
        events.append("cleanup")


print("[result] successful operation:", operation(False), events)
events.clear()
try:
    operation(True)
except RuntimeError:
    pass
print("[event] failed operation cleanup:", events)

Both paths include cleanup. Context managers package this pattern and should be preferred for resources. A with statement makes acquisition and release boundaries visible and allows reusable policy in __exit__ or __aexit__.

Never return from finally merely to silence a failure: that replaces a pending return or exception. Python 3.14's compiler emits a SyntaxWarning for return, break, or continue in a finally block because these exits often discard exceptions. The syntax remains version-specific behavior; clear cleanup code avoids it entirely.

Experiment 4: preserve cause while translating

Implicit context and explicit cause answer different diagnostic questions.

Pyodide / WebAssembly
def parse_port(text):
    try:
        return int(text)
    except ValueError as error:
        raise RuntimeError(f"invalid port: {text!r}") from error


try:
    parse_port("eighty")
except RuntimeError as error:
    print("[error] direct cause type:", type(error.__cause__).__name__)
    print("[check] context display suppressed:", error.__suppress_context__)

raise NewError(...) from error sets __cause__ and tells traceback rendering to show the direct cause. Raising while another exception is handled otherwise sets __context__ automatically. from None suppresses display of that context when lower-level details are irrelevant to users, though the context attribute remains available.

Translate at abstraction boundaries, preserve structured details, and avoid logging before re-raising unless ownership requires it. Logging at every layer produces duplicate stack traces. The boundary that converts failure into a response, job status, or process exit is usually the right reporting owner.

Experiment 5: bare raise preserves the traceback

Compare re-raising the active exception with raising the captured object.

Pyodide / WebAssembly
import traceback


def fail():
    raise KeyError("missing")


def relay():
    try:
        fail()
    except KeyError:
        raise


try:
    relay()
except KeyError as error:
    names = [frame.name for frame in traceback.extract_tb(error.__traceback__)]
    print("[state] retained traceback frames:", names[-2:])

The traceback retains relay and fail. A bare raise is the idiomatic way to propagate the currently handled exception after adding context or performing local cleanup. raise error can add the re-raise site to traceback history and make the origin noisier.

Tracebacks hold frames; frames hold locals. Queuing exception objects indefinitely can retain requests, credentials, and large object graphs. Store a formatted or sanitized diagnostic when full live traceback state is unnecessary, and clear references after reporting.

Experiment 6: exception variables are cleared

Python breaks a common reference cycle after a handler.

Pyodide / WebAssembly
def handled_name():
    try:
        raise ValueError("temporary")
    except ValueError as error:
        print("[error] handled message:", str(error))
    try:
        return error
    except UnboundLocalError as missing:
        return type(missing).__name__


print("[check] cleared handler variable raises:", handled_name())

The handler target is deleted when the suite finishes, so the function returns UnboundLocalError. An exception references its traceback, the traceback references the frame, and the frame's locals could otherwise reference the exception. Clearing the target helps break that cycle.

If an exception must outlive the handler, copy the specific fields needed or assign it deliberately to another name while accepting lifetime consequences. Do not depend on CPython reference counting for immediate cleanup; Python guarantees reachability semantics, not collection timing.

Experiment 7: exception groups split by type

Concurrent work can fail in several independent ways. ExceptionGroup and except* preserve that multiplicity.

Pyodide / WebAssembly
group = ExceptionGroup(
    "batch failed",
    [ValueError("bad row"), KeyError("missing field")],
)

try:
    raise group
except* ValueError as values:
    print("[error] ValueError leaves:", len(values.exceptions))
except* KeyError as keys:
    print("[error] KeyError leaves:", len(keys.exceptions))

Both handlers run against matching subgroups. This differs from ordinary except, where the first matching clause handles one active exception. Unmatched portions are recombined and continue propagating.

Do not flatten a task group's failures into the first message. Preserve grouping for diagnostics and selectively handle only failures for which recovery is valid. Exception-group structure and traceback rendering were introduced in 3.11; libraries supporting older Python versions need an explicit compatibility strategy.

Experiment 8: inspect the exception table

Since CPython 3.11, protected regions are represented primarily by a code object's exception table rather than setup opcodes executed on the happy path.

import dis


def safe_divide(left, right):
    try:
        return left / right
    except ZeroDivisionError:
        return None


print(bool(safe_divide.__code__.co_exceptiontable))
print(safe_divide(6, 2), safe_divide(6, 0))
for item in dis.get_instructions(safe_divide):
    print(item.opname)

The table is non-empty, and behavior is 3.0 None. Disassembly includes handler machinery, but there need not be an old-style block-setup instruction around the protected division. "Zero cost" means the normal path avoids certain interpreter setup work; it does not mean raising is free or that try has no code-size and compiler consequences.

co_exceptiontable is an opaque CPython encoding. Editing bytecode without rebuilding it can make handlers wrong or crash assumptions in tooling. Transform source or AST and let compile() generate coherent instructions, positions, stack depth, and exception ranges.

The hidden work of raising

At the language level, a traceback records where propagation traveled. CPython must update per-thread exception state, associate traceback nodes with frames, consult table entries for protected ranges, restore stack depth, execute cleanup, and continue searching. Formatting is additional work that walks traceback data and retrieves source lines.

This machinery explains two performance facts without turning them into blanket rules. First, entering a try block on modern CPython can be cheap. Second, actually raising and formatting exceptions remains substantially more work than a simple branch. Exact costs depend on depth, traceback handling, build, and release.

Use EAFP when the attempted operation is authoritative and failure is exceptional, especially when a pre-check would race or duplicate work. Use LBYL when invalid input is common, validation is part of the contract, or attempting the operation has undesirable side effects. Semantics and expected frequency decide; slogans do not.

Context managers decide suppression

When a with body raises, Python calls __exit__ with the exception type, value, and traceback. Returning a truthy value suppresses that exception; returning false leaves it propagating. Suppression is a control-flow decision and should be narrow. A transaction manager may roll back and propagate. A testing helper may deliberately suppress one expected class. A broad return True can hide programming defects.

contextlib.contextmanager lets generator code express the same protocol around one yield. Exceptions from the body are thrown back into the generator at that point. If generator cleanup catches an exception and does not re-raise it, the context manager has suppressed the failure. Review such helpers with the same care as explicit __exit__ methods.

Asynchronous context managers add cancellation pressure. Cleanup may itself await and be cancelled. Libraries must define whether cleanup is shielded, retried, or allowed to abort. That policy belongs to the concurrency abstraction, but the exception lesson remains: cleanup paths are executable control flow that can replace the original failure.

Exceptions across API boundaries

A library should not expose every implementation exception accidentally. Database driver errors, parser errors, and operating-system failures can be wrapped in a stable domain exception while preserving __cause__. Conversely, wrapping every exception in one ApplicationError destroys the distinction between bad input, unavailable dependencies, and programmer defects.

HTTP and job systems need an explicit mapping from domain failures to external status. Validation might become a client error, temporary dependency failure a retryable status, and violated invariant a server error with an incident identifier. Keep the original traceback in trusted logs, but do not send internals or secret-bearing messages to clients.

Retries deserve special care. An exception class alone does not prove an operation is safe to repeat. The failed attempt may have committed a remote side effect before losing the response. Retry policy needs idempotency semantics, attempt limits, backoff, cancellation handling, and preservation of the final cause.

Observability without distortion

Exception metrics should use bounded labels such as domain class and operation, not full messages containing IDs. Record groups without exploding every unique traceback into a metric dimension. Logs can carry richer structured fields and chained tracebacks; traces can mark the span where ownership handles the failure.

Warnings are a different mechanism for conditions that permit execution to continue. Do not raise and catch exceptions merely to emit deprecations; use warnings so callers can filter, test, and escalate them through documented policy.

Finally, syntax and type errors during import are exceptions too. Catching broad exceptions around plugin imports can convert a broken installed plugin into an apparently absent optional dependency. Catch ModuleNotFoundError narrowly, inspect its name, and let internal failures remain visible.

Practical decisions

  • Define a small domain exception hierarchy around failures callers can handle.
  • Catch narrowly and only where recovery, translation, cleanup, or reporting is possible.
  • Use raise ... from ... when crossing abstraction boundaries; use bare raise to propagate.
  • Put resource cleanup in context managers or simple finally suites that cannot suppress failures accidentally.
  • Report an exception once at the ownership boundary and retain live traceback objects only when necessary.
  • Preserve exception groups from concurrent operations rather than discarding sibling failures.
  • Never parse exact exception messages when classes and attributes provide structure.
  • Benchmark expected failure rates before replacing clear exception-based code for speed.

Exercises

  1. Build three exception subclasses under one domain base and demonstrate selective recovery at two layers.
  2. Compare traceback output for bare raise, raise error, raise New from error, and raise New from None.
  3. Write a context manager whose __exit__ logs an exception but returns False; then prove the exception still propagates.
  4. Create a nested ExceptionGroup, handle one leaf type with except*, and inspect the unhandled remainder.
  5. Measure successful and failing dictionary lookup strategies with realistic hit rates, recording environment and full samples.
  6. Disassemble try/finally on CPython 3.14 and another release. Separate behavior that stayed stable from machinery that changed.

Keep this model

An exception is an object carried by non-local control flow. Raising transfers control through protected regions and frames; matching uses classes; unwinding runs cleanup; chaining records causal layers; tracebacks preserve the route; groups preserve concurrent multiplicity. CPython 3.14 accelerates normal protected paths with exception tables, but that encoding is not the language.

Design exception boundaries as carefully as return types. Preserve the information an operator or caller needs, release machinery that retains frames, and catch only what the current layer can responsibly change.

Primary sources