The with Statement and Cleanup
The
withstatement is the syntax that drives the context manager protocol:with EXPR as TARGET: SUITEcompiles to bytecode that loads the manager’s__enter__/__exit__, calls__enter__, runs the body, and then calls__exit__on both the normal and the exceptional exit path — the latter passing the live exception in and conditionally suppressing it. The decisive 3.14 fact most write-ups get wrong: there is noBEFORE_WITHopcode anymore. CPython 3.14 removedBEFORE_WITH/BEFORE_ASYNC_WITHand now compileswithusing the newLOAD_SPECIALinstruction plus an ordinaryCALL, with the entire cleanup expressed as a hidden entry in the zero-cost exception table rather than a runtimeSETUP_FINALLYblock. The change is recorded verbatim in the changelog: “Add the newLOAD_SPECIALinstruction. Generate code forwithandasync withstatements using the new instruction. Removed theBEFORE_WITHandBEFORE_ASYNC_WITHinstructions. (Contributed by Mark Shannon in gh-120507.)” (per What’s New in 3.14). This note owns the bytecode and control flow; the sibling The Context Manager Protocol owns the method contract. Every disassembly below was produced on a local CPython 3.14.5 interpreter (python3.14 --version→3.14.5).
The with statement itself comes from PEP 343 (Python 2.5); the parenthesized multi-item form arrived with the PEG parser in 3.9/3.10 (PEP 617). The bytecode shape, however, has been rewritten repeatedly — SETUP_WITH (≤3.10), then BEFORE_WITH (3.11–3.13), now LOAD_SPECIAL+CALL (3.14) — so version discipline matters more here than almost anywhere in the interpreter.
Mental Model
The compiler turns with into two stretches of code joined by the exception table. The straight-line path evaluates the manager, stashes a bound __exit__ on the value stack for later, calls __enter__, runs the body, then calls __exit__(None, None, None) and falls through. The handler path — a separate run of instructions, not reached by any explicit jump in the straight-line code — is registered in the code object’s exception table as covering the body’s instruction range; if anything in the body raises, the interpreter’s unwinder consults the table, lands on this handler, and the handler calls __exit__(exc_type, exc, tb) and decides (via the boolean return) whether to re-raise or continue.
graph TD EVAL["LOAD_FAST cm · COPY 1<br/>LOAD_SPECIAL __exit__ · SWAP 2 · SWAP 3<br/>(bound __exit__ stashed below)"] --> CALLENTER["LOAD_SPECIAL __enter__ · CALL 0<br/>(call __enter__, push its result)"] CALLENTER --> STORE["STORE_FAST x<br/>(bind 'as' target — L1)"] STORE --> BODY["body bytecode<br/>(covered by exc-table entry L1→L3)"] BODY -->|"falls through (no exception)"| NORMEXIT["LOAD_CONST None ×3 · CALL 3 · POP_TOP<br/>(__exit__(None,None,None))"] NORMEXIT --> RET["LOAD_CONST None · RETURN_VALUE"] BODY -.->|"raises → unwinder reads exc table"| HANDLER["L3: PUSH_EXC_INFO<br/>WITH_EXCEPT_START (calls __exit__(type,val,tb))"] HANDLER --> TOBOOL["TO_BOOL · POP_JUMP_IF_TRUE"] TOBOOL -->|"true → suppress"| POPCONT["POP_TOP · POP_EXCEPT · ... · continue"] TOBOOL -->|"false → re-raise"| RERAISE["NOT_TAKEN · RERAISE 2"]
Figure: The compiled shape of with cm as x:. The solid path is the normal flow; __exit__ is called twice in the source code but only once per run — once on the normal path with three Nones, once on the handler path with the real exception. The dotted edge is not a jump: the body has no instruction that branches to the handler. Instead the body’s instruction range is registered in the exception table (L1 to L2 -> L3), so the unwinder jumps there only if an exception is raised. That is the essence of “zero-cost”: the happy path executes no setup instruction for the handler at all.
What with cm as x: Actually Compiles To
Here is the real disassembly of a one-item with, captured on CPython 3.14.5:
def f(cm):
with cm as x:
body(x) 2 LOAD_FAST_BORROW 0 (cm) # push the context manager
COPY 1 # duplicate it on the stack
LOAD_SPECIAL 1 (__exit__) # type-level lookup of __exit__ on the copy
SWAP 2 # rearrange so __exit__ sits below the manager
SWAP 3
LOAD_SPECIAL 0 (__enter__) # type-level lookup of __enter__
CALL 0 # call __enter__() with zero args; push result
L1: STORE_FAST 1 (x) # bind the result to the `as` target
3 LOAD_GLOBAL 1 (body+NULL) # ── body begins ──
LOAD_FAST_BORROW 1 (x)
CALL 1
POP_TOP # discard body(x)'s result ── body ends ──
2 L2: LOAD_CONST 0 (None) # normal-exit cleanup:
LOAD_CONST 0 (None)
LOAD_CONST 0 (None) # push (None, None, None)
CALL 3 # __exit__(None, None, None)
POP_TOP # discard its return (ignored on normal exit)
LOAD_CONST 0 (None)
RETURN_VALUE
L3: PUSH_EXC_INFO # ── exception handler begins (exc-table target) ──
WITH_EXCEPT_START # call __exit__(type, val, tb) on the in-flight exc
TO_BOOL # coerce its return to a strict bool
POP_JUMP_IF_TRUE 2 (to L4) # truthy → suppress (jump to cleanup)
NOT_TAKEN
RERAISE 2 # falsy → re-raise the original exception
L4: POP_TOP
L5: POP_EXCEPT # restore exception state
POP_TOP
POP_TOP
POP_TOP
LOAD_CONST 0 (None)
RETURN_VALUE
-- L6: COPY 3 # safety net: error *inside* the handler
POP_EXCEPT
RERAISE 1
ExceptionTable:
L1 to L2 -> L3 [2] lasti # body range → handler L3
L3 to L5 -> L6 [4] lasti # handler range → safety-net L6
Reading it instruction by instruction:
LOAD_FAST_BORROW 0 (cm)pushes the context-manager expression’s value. (LOAD_FAST_BORROWis itself a 3.14 addition that loads a local without incrementing its refcount when the interpreter can prove the frame keeps it alive — see What’s New 3.14; for our purposes treat it as “pushcm”.)COPY 1duplicates the top of stack. We needcmtwice: once to find__exit__(stashed for the exit), once to find and call__enter__.LOAD_SPECIAL 1 (__exit__)performs a special-method lookup of__exit__on the copy. The dis docs define it: “Performs special method lookup onSTACK[-1]. Iftype(STACK[-1]).__xxx__is a method, leavetype(STACK[-1]).__xxx__; STACK[-1]on the stack” (per dis, Added in version 3.14). This is the bytecode realization of the rule that__enter__/__exit__are found on the type, bypassing the instance dict (see The Context Manager Protocol and special method lookup).SWAP 2/SWAP 3reorder the stack so the bound__exit__(and itsself) sit below the manager — parked there until the cleanup runs, possibly thousands of instructions later. This is the modern replacement for whatBEFORE_WITHdid atomically in one opcode.LOAD_SPECIAL 0 (__enter__)thenCALL 0looks up and calls__enter__()with zero arguments, pushing its return value.STORE_FAST 1 (x)at labelL1binds that return to theastargetx. The label matters:L1is the start of the protected range in the exception table.- The body (
LOAD_GLOBAL body…POP_TOP) runs with no per-instruction bookkeeping. Crucially, there is noSETUP_FINALLYorSETUP_WITHin the final bytecode — the protection is entirely in the table. - At
L2begins the normal-exit cleanup: push threeNones andCALL 3invokes the parked__exit__(None, None, None), thenPOP_TOPdiscards the return (ignored on a clean exit — you cannot “suppress” a non-exception). L3onward is the exceptional path. It is not jumped to by any body instruction.PUSH_EXC_INFOsaves the current exception state and pushes the live exception;WITH_EXCEPT_STARTcalls the parked__exit__with the exception triple (detailed next);TO_BOOL+POP_JUMP_IF_TRUEbranch on the return — truthy jumps to the suppression cleanup atL4, falsy falls toRERAISE 2, which re-raises the original exception (the2oparg means it also restoresf_lasti).NOT_TAKENis a 3.14 no-op the interpreter uses only to record branch-monitoring events forsys.monitoring(per dis, Added in version 3.14).- The
L6safety net (in the table asL3 to L5 -> L6) catches an exception raised inside the handler itself — e.g. if__exit__raises a brand-new exception — and re-raises after restoring exception state.
WITH_EXCEPT_START in Detail
The single opcode that calls __exit__ on the error path is WITH_EXCEPT_START. Its description: “Calls the function in position 4 on the stack with arguments (type, val, tb) representing the exception at the top of the stack. Used to implement the call context_manager.__exit__(*exc_info()) when an exception has occurred in a with statement” (per dis, Added in version 3.9; Changed in 3.11 so “the __exit__ function is in position 4 of the stack rather than 7. Exception representation on the stack now consist of one, not three, items”). The interpreter source spells out the stack layout it expects (from Python/bytecodes.c at the v3.14.5 tag):
inst(WITH_EXCEPT_START, (exit_func, exit_self, lasti, unused, val -- exit_func, exit_self, lasti, unused, val, res)) {
/* At the top of the stack are 4 values:
- val: TOP = exc_info()
- lasti: THIRD = lasti of exception in exc_info()
- exit_self: FOURTH = the context or NULL
- exit_func: FIFTH = the context.__exit__ function or bound method
We call FOURTH(type(TOP), TOP, GetTraceback(TOP)).
Then we push the __exit__ return value. */
...
exc = PyExceptionInstance_Class(val_o); // type(exc)
PyObject *original_tb = tb = PyException_GetTraceback(val_o); // exc.__traceback__
if (tb == NULL) { tb = Py_None; }
PyObject *stack[5] = {NULL, exit_self, exc, val_o, tb}; // (type, val, tb)
int has_self = !PyStackRef_IsNull(exit_self);
PyObject *res_o = PyObject_Vectorcall(exit_func_o, stack + 2 - has_self,
(3 + has_self) | PY_VECTORCALL_ARGUMENTS_OFFSET, NULL);
...
res = PyStackRef_FromPyObjectSteal(res_o); // push __exit__'s return
}The key insight from the source: WITH_EXCEPT_START reconstructs the legacy (type, value, traceback) triple from the single exception object that 3.11+ tracks internally — it extracts the class with PyExceptionInstance_Class, the traceback with PyException_GetTraceback (substituting None if absent), and passes the value as-is. It calls the parked __exit__ (the exit_func/exit_self left on the stack at entry) via PyObject_Vectorcall and pushes the return for the following TO_BOOL/POP_JUMP_IF_TRUE to test. This opcode is the precise bridge between the single-object exception model of the runtime and the three-argument __exit__ signature of the protocol.
The Compiler’s Own Pseudocode
The code generator documents the exact shape it emits. From Python/codegen.c (codegen_with_inner, v3.14.5):
Implements the with statement from PEP 343.
with EXPR as VAR: BLOCK
is implemented as:
<code for EXPR>
SETUP_WITH E
<code to store to VAR> or POP_TOP
<code for BLOCK>
LOAD_CONST (None, None, None)
CALL_FUNCTION_EX 0
JUMP EXIT
E: WITH_EXCEPT_START (calls EXPR.__exit__)
POP_JUMP_IF_TRUE T:
RERAISE
T: POP_TOP (remove exception from stack)
POP_EXCEPT
POP_TOP
EXIT:
Two things to notice. First, the comment names SETUP_WITH — but that is a pseudo-instruction, not something you ever see in final bytecode. Python/bytecodes.c defines it as pseudo(SETUP_WITH, (-- unused), (HAS_ARG)) = { NOP } with the comment “If an exception is raised, restore the stack position to the position before the result of __(a)enter__ and push 2 values before jumping to the handler.” The compiler emits SETUP_WITH during code generation to mark where the protected region begins and what stack depth to restore on unwind; a later pass (flowgraph/assembler) consumes it and writes the corresponding exception-table entry, then drops the pseudo-instruction. That is why the disassembly shows an ExceptionTable row (L1 to L2 -> L3 [2] lasti) where the comment shows SETUP_WITH E. The [2] is the stack depth to unwind to; lasti means the handler also wants the faulting instruction offset. See Code Objects for the exception table’s binary layout and Zero-Cost Exception Handling for how the unwinder reads it.
Second, the comment is a schematic; the real 3.14 prologue is the LOAD_SPECIAL/CALL/SWAP dance shown earlier, not the literal opcodes in the comment (the comment predates and post-dates several rewrites). Trust the disassembly over the comment.
Multiple Context Managers
with A() as a, B() as b: is defined by the language reference to be exactly nested single-item withs: “With more than one item, the context managers are processed as if multiple with statements were nested” (per compound statements), i.e. equivalent to:
with A() as a:
with B() as b:
SUITEThe compiler implements this literally by recursion: codegen_with_inner(c, s, pos) emits the prologue for item pos, then recurses to codegen_with_inner(c, s, pos+1) for the body — so two items produce two LOAD_SPECIAL/CALL prologues and two separate exception-table entries, the inner one nested inside the outer. The disassembly bears this out: a two-item with produces two __enter__ prologues, two WITH_EXCEPT_START handlers, and an exception table with rows for each nesting level (L1 to L2 -> L9, L2 to L3 -> L5, …). The practical consequence: if B()’s __enter__ raises, A’s __exit__ does run (its bracket is balanced), but B’s does not (its __enter__ never returned) — exactly the nested-with semantics.
The parenthesized form,
with (
A() as a,
B() as b,
):
SUITEis purely a parser affordance added in 3.10 via the PEG parser (PEP 617); it lets you wrap multiple items across lines and allows a trailing comma. It compiles to identical bytecode to the unparenthesized multi-item form — the parentheses change only what the grammar accepts, not the lowering (“Changed in version 3.10: Support for using grouping parentheses to break the statement in multiple lines”, per the reference).
async with Lowering
async with uses __aenter__/__aexit__, both of which return awaitables, so the lowering interleaves the protocol calls with the await machinery (see Coroutines and the async await Protocol). The compiler comment in codegen_async_with_inner gives the semantics:
value = await context.__aenter__()
try:
VAR = value
BLOCK
finally:
if an exception was raised: exc = (exception, instance, traceback)
else: exc = (None, None, None)
if not (await exit(*exc)): raise
In bytecode this becomes the same LOAD_SPECIAL __aexit__ / SWAP / SWAP / LOAD_SPECIAL __aenter__ / CALL prologue, immediately followed by the await sequence — GET_AWAITABLE 1 (oparg 1 meaning “after a call to __aenter__”, per dis), then a SEND/YIELD_VALUE/RESUME loop that drives the awaitable to completion. The normal-exit cleanup likewise calls __aexit__(None, None, None) and then GET_AWAITABLE 2 (oparg 2 = “after a call to __aexit__”) and awaits it. The exceptional handler runs WITH_EXCEPT_START and then awaits its result before TO_BOOL/POP_JUMP_IF_TRUE. The BEFORE_ASYNC_WITH opcode that did the prologue in 3.11–3.13 is gone for the same reason BEFORE_WITH is (per What’s New 3.14). async with is a SyntaxError outside a coroutine function (per the reference).
Version History — Why BEFORE_WITH Confusion Is Endemic
The bytecode for with has changed shape three times, and stale tutorials cite all three:
- ≤ 3.10: a real
SETUP_WITHopcode handled the prologue and registered cleanup on a per-frame block stack. The 3.10 dis docs describeSETUP_WITH (delta)as performing “several operations before a with block starts” — loading__exit__()and pushing it for later use byWITH_EXCEPT_START, then calling__enter__()— and the same docs repeatedly refer to “the block stack” as the runtime structure from which exception-handler blocks are pushed and removed (per dis 3.10). So cleanup before 3.11 was genuinely block-stack-driven, not table-driven. - 3.11 – 3.13: the zero-cost exception rewrite (3.11) eliminated the runtime block stack in favour of the exception table, and the prologue became a single
BEFORE_WITHopcode that loaded__exit__, called__enter__, and pushed the result. The 3.13 dis docs still describe it: “This opcode performs several operations before a with block starts. First, it loads__exit__()… Then,__enter__()is called. Finally, the result … is pushed” (per dis 3.13, Added in version 3.11). - 3.14:
BEFORE_WITHandBEFORE_ASYNC_WITHwere removed and replaced by the genericLOAD_SPECIAL+CALLsequence (gh-120507, Mark Shannon), reusing the same special-method-lookup opcode the interpreter uses elsewhere instead of a bespoke per-feature opcode (per What’s New 3.14). The cleanup itself was already a table-driven hidden handler since 3.11; 3.14 only changed the prologue.
Resolved (2026-06-01)
The pre-3.11
SETUP_WITH+ block-stack description is verified against the 3.10 dis docs:SETUP_WITHloaded__exit__/called__enter__, and cleanup ran off a per-frame “block stack.”
Failure Modes Visible in the Bytecode
__enter__raising never reaches the handler. In the disassembly the protected range starts atL1(theSTORE_FASTafterCALLreturns). If__enter__raises, the failure happens beforeL1, outside any exception-table row for thiswith, soWITH_EXCEPT_START(and thus__exit__) never runs — matching the protocol guarantee in The Context Manager Protocol.- A truthy
__exit__swallows everything. ThePOP_JUMP_IF_TRUEafterTO_BOOLis the only gate between an exception and its suppression. A__exit__that returns any truthy value makes that branch taken for every exception, including ones you never meant to catch — the bytecode has no type filter; the filtering (if any) is entirely inside__exit__. - An exception inside
__exit__is caught by theL6/L12safety-net rows (L3 to L5 -> L6) and re-raised, chaining the original via__context__. This is why a buggy__exit__can mask the original error with its own.
See Also
- The Context Manager Protocol — the sibling note: the
__enter__/__exit__contract this bytecode drives (return-value binding, the suppress rule,contextlib). - Zero-Cost Exception Handling — how the exception table replaces
SETUP_FINALLY; thewithcleanup is one of its hidden handlers. - Exception Handling Internals — the unwinder that reads the table and lands on
WITH_EXCEPT_START. - Code Objects —
co_exceptiontable, the binary table thewithcleanup lives in. - Coroutines and the async await Protocol — the
awaitmachineryasync withinterleaves with. - Generators and the yield Mechanism — the
SEND/YIELD_VALUE/RESUMEloop reused byasync with. - Python Internals MOC, §14 “Exceptions and Control-Flow Internals”.