skip to content

Why does Python unbind the name in `except ValueError as exc` when the clause ends?

level: middleimportance: should knowfreq 28%

answer

  1. It is not a normal assignment
  2. The compiler adds a cleanup step
  3. Follow the references from the exception
  4. Frames and their locals stay alive
  5. Copy it out if you need it later

basics

~20 s

Because the exception references its traceback, which references the frames, which reference the exception - a cycle that would keep the whole stack alive. Python deletes the name at the end of the clause; assign the exception elsewhere first if you need it later.

solid answer

~40 s

`except E as exc:` compiles to a `finally`-style `del exc` after the clause body, so referring to `exc` afterwards raises `NameError` even at module level. The reason is retention: the exception holds `__traceback__`, the traceback holds the frames, and the handler's own frame holds the exception, so a lingering binding pins every local variable in the failed call stack. `sys.exc_info()` is likewise only meaningful inside the handler's dynamic extent - afterwards it is `(None, None, None)`. If you want a deferred post-mortem, bind it deliberately inside the clause (`captured = exc`) and accept that you are holding those frames until you drop it - which is exactly why the usual advice is to format or capture the state and then let the exception go.

code

python · 12 lines
python
import sys

captured = None
try:
    raise ValueError("stale bid")
except ValueError as exc:
    captured = exc
    print(sys.exc_info()[1] is exc)

print("exc" in dir())
print(sys.exc_info())
print(captured.__traceback__ is not None)

go deeper

for a junior

Remember the concrete rule: the name after as disappears when the except clause ends, so using it afterwards is a NameError. If you need the exception later, assign it to another variable inside the clause.

for a middle

Explain the mechanism and the motive: a compiler-inserted deletion, there because exception to traceback to frame to locals is a cycle that would pin the whole failed stack. Know that sys.exc_info() is scoped to the handler too.

for a senior

Connect it to production symptoms: a service whose memory tracks its error rate, or a failure buffer that holds live exceptions. Be ready to say what you keep instead - rendered text or captured values - and for how long you hold a live exception.

for a principal

Set the convention for the codebase: what error paths may retain, what they must render, and how failure artefacts are produced consistently so no team invents its own exception-holding cache that leaks under load.

### The rule In Python 3 the target of `except ... as name` is not an ordinary assignment. The compiler wraps the handler body so that when the clause finishes - normally, by exception, or by `return` - it executes the equivalent of `del name`. The name is unbound afterwards, and a later reference raises `NameError`. This surprises people because it does not match any other binding form in the language: a `for` target, a `with` target and a walrus binding all survive their block. It also means the target name is *clobbered*: if you already had a variable called `exc` in scope, the handler destroys it, bound or not. ```python try: raise ValueError("stale bid") except ValueError as exc: print(type(exc).__name__) print("exc" in dir()) # False ``` ### The reason: a retention cycle This exists to solve a memory problem. An exception instance carries `__traceback__`. That traceback chain references the frame objects the exception passed through. Each frame references everything its local variables point at - the request payload, the open file, the large list you were building. And the handler's frame references the exception through the target name. That is a reference cycle whose members are, collectively, the entire failed call stack. CPython's cycle collector can eventually reclaim it, but 'eventually' is the wrong answer for a loop that handles thousands of exceptions, and a function that keeps a bound exception in a long-lived frame - a generator, a coroutine, an object attribute - never releases it at all. Deleting the name at the end of the clause makes the common case deterministic: the moment the handler finishes, the exception and everything it was pinning become garbage. ### What this means for post-mortem work This is precisely the point where post-mortem technique meets language semantics. If you want to inspect the failure *later* - hand the traceback to a debugger after some cleanup, or push the exception onto a queue for a reporting thread - you must copy the reference out of the target name inside the clause: ```python captured = None try: settle(order) except IndexError as exc: captured = exc # survives the clause if captured is not None: print(captured.__traceback__ is not None) ``` The copy is legitimate, and the traceback is still attached, so a later `pdb.post_mortem(captured)` works. But you have re-created exactly the retention the deletion was there to prevent: `captured` pins every frame and every local in that stack for as long as it lives. In a request loop or a 'keep the last N failures' buffer that is a genuine leak, and it is a common cause of a service whose memory climbs in proportion to its error rate. The usual resolution is to hold the exception only long enough to render what you need from it, then drop it. ### `sys.exc_info()` has the same lifetime, differently expressed `sys.exc_info()` returns the `(type, value, traceback)` triple for the exception being handled right now, per thread, and it works anywhere in the dynamic extent of the handler - including inside functions the handler calls, which is how a logging helper gets the traceback without being passed one. Outside a handler it is `(None, None, None)`. Since 3.11, `sys.exception()` returns just the instance. Neither is a way to retrieve an exception after the fact; there is no 'last exception' you can ask for later in ordinary code. The only durable handle is one you took yourself. ### The bare `raise` idiom Note what the deletion does *not* break. A bare `raise` inside the handler re-raises the exception being handled without naming it, so it never depends on the target binding, and it preserves the original traceback rather than starting a new one from the handler. That is the idiom to reach for when a handler logs or captures and then wants the failure to continue propagating - it needs no reference of its own, and so leaves no retention behind once the clause exits. ### Practical consequences Three habits follow. First, if a handler needs to re-raise later or report asynchronously, bind the exception explicitly and document that you are holding frames. Second, prefer rendering to holding: a formatted string, or a capture of the frames' values, has no references into the failed stack. Third, do not name your `except` target after a variable you still need - the deletion is unconditional and silent.

  • How do you keep an exception for later inspection without leaking the frames it holds?
    Render it and drop it. Inside the handler, produce what you actually need - the formatted traceback, or a capture of the frames' values - and let the exception be collected when the clause ends. Text has no references into the failed stack, so it is safe to queue, buffer or keep. Hold the live exception only when you genuinely need a debugger prompt on it, and for as short a window as possible.
  • Is the deletion skipped if the handler returns or raises early?
    No. The compiler emits the cleanup in a `finally`-like position, so it runs however the clause exits - falling off the end, `return`, `break`, or another exception propagating out of the handler. There is no path where the target name survives, which is why code that wants the exception afterwards must copy the reference during the clause.
  • Does `sys.exc_info()` still work inside a function called from the handler?
    Yes. It reports the exception being handled anywhere in the handler's dynamic extent on that thread, so a helper called from the `except` block sees the same triple without it being passed in. That is the mechanism reporting utilities use. It stops working once the handler ends, and it is per-thread, so a helper running on a different thread sees nothing.

saying these in an interview costs you the question

  • Thinks the target name survives like a `for` or `with` target
  • Blames scoping rules rather than reference retention
  • Expects `sys.exc_info()` to return the last exception forever
  • Stores exception objects in a buffer without noticing the retention
  • Believes only the message and type are held by the exception
  • Assumes an early `return` skips the cleanup

context