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 fromBaseException— 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 theraisestatement compiles to theRAISE_VARARGSopcode, how_PyErr_SetObject()installs the exception and performs implicit chaining, how thetry/except/else/finallyopcodes (PUSH_EXC_INFO,CHECK_EXC_MATCH,RERAISE,POP_EXCEPT) consume it, and how propagation walks back through frames. The side table that decides whichtryblock 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 bysys.exit(); carries the exit code inargs[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, enablingraise 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 byraise ... from ...).__suppress_context__— a boolean that, when true, tells the traceback printer to hide__context__.__notes__andadd_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 bareraise— 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 atSTACK[-2], setting its__cause__toSTACK[-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 itsexc_typeandexc_tracebackfields in 3.11, keeping onlyexc_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 oldcurexc_*triple, collapsed to one instance in 3.12 (verified againstInclude/cpython/pystate.hat thev3.11.0vsv3.12.0tags).
_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+) — implementsexcept SomeType:. It tests whetherSTACK[-2](the live exception) matchesSTACK[-1](the type or tuple of types in theexceptclause), pops the type, and pushes the boolean result. A false result means this clause does not apply and control falls through to the nextexceptor 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’sf_lasti, so the re-raised exception is associated with the original failing instruction rather than the location inside thefinallyblock (more below).POP_EXCEPT— runs when a handler finishes; it pops the saved value (pushed byPUSH_EXC_INFO) to restore the previous exception-handling state, sosys.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:
- Inside
g,RAISE_VARARGS 1calls_PyErr_SetObject(), which (finding no handled exception ing) sets__context__to nothing, normalizes theKeyErrorinstance, and stores it intstate->current_exception. - The interpreter loop in
_PyEval_EvalFrameDefault(Python/ceval.c) leaves normal dispatch and jumps to itsexception_unwindpath. It callsget_exception_handler()withg’s current instruction offset. ghas no covering handler, so the lookup returns 0.PyTraceBack_Here()(inPython/traceback.c) prepends a traceback entry namingg’s frame and line.g’s frame is popped;_PyEval_EvalFrameDefaultforgreturns NULL to its callerf.- Back in
f’s frame, the sameexception_unwindlogic runs for theCALLinstruction that invokedg.get_exception_handler()now finds thetryblock’s entry: it returns the handler target offset, the stack depth to unwind to, and thelastiflag. - The interpreter pops
f’s value stack down to the recorded depth, optionally pusheslasti, pushes theKeyError, and jumps to the handler target.PUSH_EXC_INFOruns;CHECK_EXC_MATCHconfirmsKeyErrormatches theexcept KeyErrorclause; the handler body executes;POP_EXCEPTrestores 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 Exceptioncatches everything.” It does not — it missesKeyboardInterrupt,SystemExit, andGeneratorExit, which descend fromBaseExceptiondirectly. This is by design. - “
raise X from Nonedeletes the original exception.” It does not delete__context__; it sets__suppress_context__ = Trueso the printer hides it. The original is still reachable onexc.__context__programmatically. - “
current_exceptionandsys.exc_info()are the same thing.” No —current_exceptionis the in-flight (propagating) exception;sys.exc_info()readsexc_info->exc_value, the being-handled exception inside anexceptblock. They can both be non-None at once during nested handling. - “
tryhas a runtime cost even when nothing is raised.” Not since 3.11 — see Zero-Cost Exception Handling.
See Also
- Zero-Cost Exception Handling — the
co_exceptiontableside table that decides which handler covers which instruction (sibling). - Traceback Objects and Stack Unwinding — the
__traceback__chain built byPyTraceBack_Here()during the unwind described here. - Exception Groups and except-star —
BaseExceptionGroup,ExceptionGroup,CHECK_EG_MATCH, andexcept*. - Stack Frames and the Frame Stack — the frame objects popped during propagation.
- The Value Stack and Frame Evaluation — the value stack the handler-entry steps manipulate.
- Python Internals MOC §14 — Exceptions and Control-Flow Internals.