Exception Handling Internals

When CPython executes raise ValueError("bad"), nothing magical happens at the language level: an ordinary heap object — an instance of a class descending from BaseException — is constructed, stamped with context, stored in a field on the current thread’s state, and the interpreter then begins unwinding its frame stack looking for code willing to catch it. This note traces that mechanism end to end as it works in CPython 3.14 (current patch 3.14.5): the exception object hierarchy, how the raise statement compiles to the RAISE_VARARGS opcode, how _PyErr_SetObject() installs the exception and performs implicit chaining, how the try/except/else/finally opcodes (PUSH_EXC_INFO, CHECK_EXC_MATCH, RERAISE, POP_EXCEPT) consume it, and how propagation walks back through frames. The side table that decides which try block covers which instruction is a separate concern, deferred to Zero-Cost Exception Handling.

Mental Model

Think of an exception not as a control-flow primitive baked into the bytecode, but as a value plus a search. The value is a BaseException instance living on the heap like any other object. The search is what the interpreter does when that value is “in flight”: it consults a per-code-object table (the exception table) to ask “is the instruction that just failed covered by a handler in this frame?” If yes, control jumps to the handler. If no, the frame is discarded, a traceback entry is recorded, and the same question is asked of the caller’s frame. The process repeats until a handler is found or the stack is exhausted (an unhandled exception that reaches the top prints the traceback and, at the top level, exits).

flowchart TD
    A["raise ValueError(...)<br/>RAISE_VARARGS opcode"] --> B["_PyErr_SetObject()<br/>build object, set __context__,<br/>store in tstate->current_exception"]
    B --> C["interpreter exits the<br/>normal opcode dispatch into<br/>the 'exception' label in ceval.c"]
    C --> D{"get_exception_handler():<br/>is current instr offset<br/>in this frame's exc table?"}
    D -->|yes| E["unwind value stack to handler<br/>depth, push exc, jump to target<br/>(PUSH_EXC_INFO runs there)"]
    D -->|no| F["PyTraceBack_Here() adds this<br/>frame to the traceback;<br/>pop frame, return to caller"]
    F --> G{"caller frame:<br/>handler for the CALL?"}
    G -->|yes| E
    G -->|no| F
    G -->|stack empty| H["_PyEval_EvalFrameDefault<br/>returns NULL;<br/>unhandled exception printed"]
    E --> I["handler body runs;<br/>POP_EXCEPT restores prior state"]

Figure: the life of a raised exception. The insight to take away is that there is no per-try bookkeeping cost at all on the happy path — the search (the diamond get_exception_handler) only runs once an exception is actually raised, and frame unwinding is just that search repeated up the call chain. The handler-lookup mechanism is the subject of Zero-Cost Exception Handling; here we focus on everything around it.

The exception object hierarchy

Every exception in Python is an instance of a class that ultimately derives from BaseException. This is the single root of the hierarchy; you cannot raise an object that is not a BaseException subclass (attempting raise 42 yields TypeError: exceptions must derive from BaseException). Directly beneath BaseException sit four classes (the exceptions reference):

  • SystemExit — raised by sys.exit(); carries the exit code in args[0].
  • KeyboardInterrupt — raised when the user hits the interrupt key (Ctrl-C).
  • GeneratorExit — raised inside a generator or coroutine when it is closed.
  • Exception — the base for all “ordinary” errors, and the class user code is meant to subclass.

The reason SystemExit, KeyboardInterrupt, and GeneratorExit descend from BaseException but not from Exception is deliberate: a bare except Exception: should not swallow a Ctrl-C or a shutdown signal. Catch-all code that genuinely wants everything must say except BaseException:. A fifth direct child, BaseExceptionGroup, was added in 3.11 to wrap multiple simultaneous exceptions; its Exception-derived counterpart is ExceptionGroup. These are the subject of Exception Groups and except-star and are not covered further here.

Under Exception sits the familiar taxonomy: ArithmeticError (with ZeroDivisionError, OverflowError), LookupError (with IndexError, KeyError), OSError (with the errno-derived subclasses like FileNotFoundError), ValueError, TypeError, RuntimeError, StopIteration, and so on. The C implementations of these built-in types live in Objects/exceptions.c; they are real C-level types (PyExc_ValueError and friends) with a shared BaseException layout.

Attributes every exception carries

A BaseException instance is mostly defined by a handful of attributes, all of which matter to propagation and display:

  • args — the tuple of positional arguments passed to the constructor. str(exc) is derived from it.
  • __traceback__ — a writable reference to the traceback object recording where the exception has travelled. with_traceback(tb) sets it and returns the exception, enabling raise exc.with_traceback(tb).
  • __context__ — the implicitly chained exception (set automatically when one exception is raised while another is being handled).
  • __cause__ — the explicitly chained exception (set by raise ... from ...).
  • __suppress_context__ — a boolean that, when true, tells the traceback printer to hide __context__.
  • __notes__ and add_note(note) — added in 3.11, a list of strings appended to the printed traceback (used heavily by the new exception groups, the exceptions reference).

RAISE_VARARGS: how raise compiles

The raise statement compiles to exactly one bytecode instruction, RAISE_VARARGS, whose operand argc selects which of the three syntactic forms is meant (the dis reference):

  • argc == 0: a bare raise — re-raise the exception currently being handled.
  • argc == 1: raise STACK[-1] — raise the instance or type on top of the stack.
  • argc == 2: raise STACK[-2] from STACK[-1] — raise the instance/type at STACK[-2], setting its __cause__ to STACK[-1].

So raise ValueError("bad") compiles to: load the ValueError type, build the call, then RAISE_VARARGS 1. The from clause is the only difference between explicit and implicit chaining at the bytecode level — __cause__ is set by RAISE_VARARGS itself (per the InternalDocs: “The __cause__ field (explicit chaining) is set by the RAISE_VARARGS bytecode”), whereas __context__ is set deep inside the C runtime, as we will see.

Where the in-flight exception lives: tstate->current_exception

When an exception is raised, it has to be stored somewhere the interpreter loop can find it. That place is a single field on the thread state, tstate->current_exception, declared in Include/cpython/pystate.h (v3.14.5) as:

/* The exception currently being raised */
PyObject *current_exception;

This single PyObject * holds the fully-normalized exception instance — not a type, not a traceback, just the instance (the type is Py_TYPE(exc) and the traceback is exc.__traceback__). It represents the exception that is in flight: raised, not yet caught. This single field replaced the older curexc_type / curexc_value / curexc_traceback triple in Python 3.12, verified directly against the headers: Include/cpython/pystate.h at the v3.11.0 tag still declares PyObject *curexc_type; (the triple), while at v3.12.0 it declares PyObject *current_exception; (the single instance). The change is part of the broader 3.12 move from exception tuples to exception instances tracked under gh-103176.

A separate and frequently-confused field is tstate->exc_info, a pointer to a _PyErr_StackItem whose exc_value member holds the exception currently being handled (i.e. the one an active except block is processing) — this is what sys.exc_info() reads. The two are genuinely different: current_exception is “what is propagating right now”, while exc_info->exc_value is “what we caught and are inside the handler for”. Conflating them is a classic error. They were also simplified in different releases:

  • The handled-exception representation (exc_info / _PyErr_StackItem) lost its exc_type and exc_traceback fields in 3.11, keeping only exc_value, because type and traceback are derivable from the value (bpo-45711, per What’s New 3.11). That same change cut the on-stack exception representation from three items to one.
  • The in-flight representation (current_exception) is the single field discussed above, the successor to the old curexc_* triple, collapsed to one instance in 3.12 (verified against Include/cpython/pystate.h at the v3.11.0 vs v3.12.0 tags).

_PyErr_SetObject and implicit chaining (__context__)

All of the C-level PyErr_Set*() convenience functions ultimately funnel into _PyErr_SetObject() in Python/errors.c. This function is where implicit chaining happens. Before storing the new exception, it asks: is there already an exception being handled? It reads the topmost handled exception:

exc_value = _PyErr_GetTopmostException(tstate)->exc_value;
if (exc_value != NULL && exc_value != Py_None) {
    /* Implicit exception chaining */
    ...
    PyException_SetContext(value, exc_value);
}

If so, the new exception’s __context__ is set to that handled exception — this is exactly the “During handling of the above exception, another exception occurred” message you see in tracebacks. Crucially, before linking, the function walks the existing __context__ chain to make sure it is not about to create a cycle (which would make traceback printing loop forever). It uses Floyd’s cycle-detection algorithm (the tortoise-and-hare), advancing a slow pointer half as fast as the fast one (quoted verbatim from Python/errors.c):

            PyObject *slow_o = o;  /* Floyd's cycle detection algo */
            int slow_update_toggle = 0;
            while ((context = PyException_GetContext(o))) {
                Py_DECREF(context);
                if (context == value) {
                    PyException_SetContext(o, NULL);
                    break;
                }
                o = context;
                if (o == slow_o) {
                    /* pre-existing cycle - all exceptions on the
                       path were visited and checked.  */
                    break;
                }
                if (slow_update_toggle) {
                    slow_o = PyException_GetContext(slow_o);
                    Py_DECREF(slow_o);
                }
                slow_update_toggle = !slow_update_toggle;
            }
            PyException_SetContext(value, exc_value);

Line by line: o is the fast pointer walking up the __context__ chain (PyException_GetContext returns a new reference, hence the Py_DECREF(context) to avoid leaking on each hop). If any link in the chain is the new exception value, that would form a cycle, so the offending __context__ is cut (SetContext(o, NULL)). slow_o is the tortoise; when o catches up to it (o == slow_o) we have detected a pre-existing cycle and stop. The slow_update_toggle makes the slow pointer advance only every other iteration — the textbook tortoise-and-hare ratio. After the walk, PyException_SetContext(value, exc_value) finally links the new exception’s context to the one being handled. The function then stores the result via _PyErr_Restore(tstate, Py_NewRef(Py_TYPE(value)), value, tb), which calls _PyErr_SetRaisedException() and ultimately writes into tstate->current_exception.

__cause__, __suppress_context__, and raise ... from

Explicit chaining is simpler and lives at the bytecode level. raise new from original sets new.__cause__ = original (via RAISE_VARARGS 2). Per the exceptions reference, setting __cause__ also implicitly sets __suppress_context__ = True. The display rule then is: an explicitly-chained __cause__ is always shown (“The above exception was the direct cause of…”), while an implicitly-chained __context__ is shown only if __cause__ is None and __suppress_context__ is false. That is why raise CleanError() from None produces a tidy traceback with the messy underlying cause hidden: from None sets __cause__ to None and flips __suppress_context__ to true, suppressing the implicit context.

The try/except/finally opcodes

On the happy path, a try block emits no setup opcode at all — the bookkeeping that pre-3.11 versions paid (SETUP_FINALLY pushing a block onto a runtime stack) was moved into the side table, the whole point of Zero-Cost Exception Handling. The opcodes below run only once an exception has been raised and a handler located (the dis reference):

  • PUSH_EXC_INFO (3.11+) — runs at the start of every handler. It pops the just-arrived exception, pushes the previously-handled exception (so it can be restored later), then pushes the current exception back on top. This is how nested handlers remember the outer exception while processing the inner one.
  • CHECK_EXC_MATCH (3.11+) — implements except SomeType:. It tests whether STACK[-2] (the live exception) matches STACK[-1] (the type or tuple of types in the except clause), pops the type, and pushes the boolean result. A false result means this clause does not apply and control falls through to the next except or re-raises.
  • RERAISE — re-raises the exception currently on the stack. If its operand is non-zero it also pops a value used to set the frame’s f_lasti, so the re-raised exception is associated with the original failing instruction rather than the location inside the finally block (more below).
  • POP_EXCEPT — runs when a handler finishes; it pops the saved value (pushed by PUSH_EXC_INFO) to restore the previous exception-handling state, so sys.exc_info() reverts correctly.

CLEANUP_THROW (3.12+) is a related instruction handling exceptions thrown into generators/coroutines via throw()/close(); it belongs to Generators and the yield Mechanism and is not detailed here. else and finally do not have dedicated opcodes — the compiler arranges control flow and duplicates the finally body (or routes through it) so the cleanup runs on every exit path.

lasti and re-raising from finally

A finally block runs on all exits, including when an exception is in flight. If that exception must propagate after the finally finishes, the interpreter needs to re-associate it with the original raising instruction — but by now the frame’s instruction pointer is inside the finally block. The exception table therefore optionally records a lasti flag; when set, the offset of the raising instruction is pushed onto the value stack, and RERAISE (with a non-zero operand) restores it. This is why a clean traceback points at the line that actually failed, not at the finally. The push-or-not decision is encoded per handler in the exception table — see Zero-Cost Exception Handling.

Mechanical walk-through: a raise that propagates two frames

Consider f() calling g(), and g() raising KeyError with f() having a try/except KeyError:

  1. Inside g, RAISE_VARARGS 1 calls _PyErr_SetObject(), which (finding no handled exception in g) sets __context__ to nothing, normalizes the KeyError instance, and stores it in tstate->current_exception.
  2. The interpreter loop in _PyEval_EvalFrameDefault (Python/ceval.c) leaves normal dispatch and jumps to its exception_unwind path. It calls get_exception_handler() with g’s current instruction offset.
  3. g has no covering handler, so the lookup returns 0. PyTraceBack_Here() (in Python/traceback.c) prepends a traceback entry naming g’s frame and line. g’s frame is popped; _PyEval_EvalFrameDefault for g returns NULL to its caller f.
  4. Back in f’s frame, the same exception_unwind logic runs for the CALL instruction that invoked g. get_exception_handler() now finds the try block’s entry: it returns the handler target offset, the stack depth to unwind to, and the lasti flag.
  5. The interpreter pops f’s value stack down to the recorded depth, optionally pushes lasti, pushes the KeyError, and jumps to the handler target. PUSH_EXC_INFO runs; CHECK_EXC_MATCH confirms KeyError matches the except KeyError clause; the handler body executes; POP_EXCEPT restores state. Propagation is over.

This frame-by-frame unwinding — record a traceback entry, pop, ask the caller — is the heart of propagation and is documented in the InternalDocs. See Stack Frames and the Frame Stack for the frame objects being popped and The Value Stack and Frame Evaluation for the value-stack manipulation the handler entry performs.

Common Misunderstandings

  • except Exception catches everything.” It does not — it misses KeyboardInterrupt, SystemExit, and GeneratorExit, which descend from BaseException directly. This is by design.
  • raise X from None deletes the original exception.” It does not delete __context__; it sets __suppress_context__ = True so the printer hides it. The original is still reachable on exc.__context__ programmatically.
  • current_exception and sys.exc_info() are the same thing.” No — current_exception is the in-flight (propagating) exception; sys.exc_info() reads exc_info->exc_value, the being-handled exception inside an except block. They can both be non-None at once during nested handling.
  • try has a runtime cost even when nothing is raised.” Not since 3.11 — see Zero-Cost Exception Handling.

See Also