The with Statement and Cleanup

The with statement is the syntax that drives the context manager protocol: with EXPR as TARGET: SUITE compiles 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 no BEFORE_WITH opcode anymore. CPython 3.14 removed BEFORE_WITH/BEFORE_ASYNC_WITH and now compiles with using the new LOAD_SPECIAL instruction plus an ordinary CALL, with the entire cleanup expressed as a hidden entry in the zero-cost exception table rather than a runtime SETUP_FINALLY block. The change is recorded verbatim in the changelog: “Add the new LOAD_SPECIAL instruction. Generate code for with and async with statements using the new instruction. Removed the BEFORE_WITH and BEFORE_ASYNC_WITH instructions. (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 --version3.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_BORROW is 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 “push cm”.)
  • COPY 1 duplicates the top of stack. We need cm twice: 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 on STACK[-1]. If type(STACK[-1]).__xxx__ is a method, leave type(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 3 reorder the stack so the bound __exit__ (and its self) sit below the manager — parked there until the cleanup runs, possibly thousands of instructions later. This is the modern replacement for what BEFORE_WITH did atomically in one opcode.
  • LOAD_SPECIAL 0 (__enter__) then CALL 0 looks up and calls __enter__() with zero arguments, pushing its return value.
  • STORE_FAST 1 (x) at label L1 binds that return to the as target x. The label matters: L1 is the start of the protected range in the exception table.
  • The body (LOAD_GLOBAL bodyPOP_TOP) runs with no per-instruction bookkeeping. Crucially, there is no SETUP_FINALLY or SETUP_WITH in the final bytecode — the protection is entirely in the table.
  • At L2 begins the normal-exit cleanup: push three Nones and CALL 3 invokes the parked __exit__(None, None, None), then POP_TOP discards the return (ignored on a clean exit — you cannot “suppress” a non-exception).
  • L3 onward is the exceptional path. It is not jumped to by any body instruction. PUSH_EXC_INFO saves the current exception state and pushes the live exception; WITH_EXCEPT_START calls the parked __exit__ with the exception triple (detailed next); TO_BOOL + POP_JUMP_IF_TRUE branch on the return — truthy jumps to the suppression cleanup at L4, falsy falls to RERAISE 2, which re-raises the original exception (the 2 oparg means it also restores f_lasti). NOT_TAKEN is a 3.14 no-op the interpreter uses only to record branch-monitoring events for sys.monitoring (per dis, Added in version 3.14).
  • The L6 safety net (in the table as L3 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:
        SUITE

The 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,
):
    SUITE

is 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 sequenceGET_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_WITH opcode handled the prologue and registered cleanup on a per-frame block stack. The 3.10 dis docs describe SETUP_WITH (delta) as performing “several operations before a with block starts” — loading __exit__() and pushing it for later use by WITH_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_WITH opcode 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_WITH and BEFORE_ASYNC_WITH were removed and replaced by the generic LOAD_SPECIAL+CALL sequence (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_WITH loaded __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 at L1 (the STORE_FAST after CALL returns). If __enter__ raises, the failure happens before L1, outside any exception-table row for this with, so WITH_EXCEPT_START (and thus __exit__) never runs — matching the protocol guarantee in The Context Manager Protocol.
  • A truthy __exit__ swallows everything. The POP_JUMP_IF_TRUE after TO_BOOL is 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 the L6/L12 safety-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