What does CPython 3.11's zero-cost exception handling change about the price of try/except?
answer
- Try blocks got cheaper in one release
- Nothing executes when you enter one
- The cost moved to the other side
- A compiled table maps offsets to handlers
- Free only while nothing raises
basics
~20 sSince 3.11, entering a try block runs no setup instruction at all. Handler locations live in a table compiled beside the bytecode and consulted only when something is actually raised, so the whole cost moved onto the raising path.
solid answer
~40 sBefore **3.11**, entering a `try` block executed a real setup instruction that pushed an entry onto the frame's block stack, and leaving it popped that entry — so guarding a hot loop body cost something on every iteration even when nothing went wrong. In 3.11 the compiler instead emits an **exception table** alongside the bytecode, mapping instruction ranges to handler offsets. The happy path executes nothing extra; only when an exception is raised does the interpreter consult that table to find where to jump. Hence "zero-cost": zero when no exception occurs. Raising is still expensive — building the exception instance, capturing a traceback, unwinding frames — so EAFP is cheap when the exception is genuinely rare, and still a bad idea as routine control flow in a hot loop.
code
python · 12 linesimport dis
def guarded(data):
total = 0
for x in data:
try:
total += x
except TypeError:
pass
return total
dis.dis(guarded)go deeper
Be ready to say that wrapping code in try/except costs nothing while no exception happens, and that raising one is the expensive part. Knowing the direction of the trade matters more than the mechanism here.
Explain what 3.11 removed (per-entry setup and teardown on the frame's block stack) and what replaced it (an exception table consulted only during unwinding), and draw the EAFP-versus-check-first conclusion from how often the failure fires.
Demonstrate the judgement: guard freely, but narrow the except clause, log with the traceback and count failures, because a swallowed exception in a worker loop turns a hard failure into silent data loss.
Own the error-handling policy across services: which exceptions are allowed to be caught and continued, what must always be surfaced as a metric or alert, and how you keep a cheap language feature from becoming an organisational habit of hiding failures.
## The old model: pay on entry Up to **3.10**, CPython implemented `try` with runtime bookkeeping. Entering the block executed an instruction that pushed a block-stack entry onto the frame recording "if something raises between here and there, jump to this handler"; leaving the block executed another instruction to pop it. That is a handful of operations per entry — individually small, but paid on *every* execution of the block, whether or not anything ever went wrong. It made people write code that avoided guarding hot loops, and it made "is try/except slow?" a real interview question with a real answer. ## The new model: pay on raise **3.11** removed the setup and teardown entirely. The compiler now emits, next to the bytecode, an **exception table**: a compact side structure mapping ranges of instruction offsets to the handler offset that covers them, along with the stack depth to restore. The happy path executes the guarded instructions and nothing else — a disassembly of a guarded loop shows no per-entry bookkeeping, at most a placeholder no-op, and prints the exception table at the end. When something *does* raise, the interpreter takes the current instruction offset, searches the table for the range covering it, restores the recorded stack depth and jumps to the handler. If no range matches, the frame is popped and the search continues in the caller. Table lookup is slower than reading a pre-pushed block-stack entry, which is exactly the trade: make the common case free and the rare case a little dearer. The name is precise if you read it literally: zero cost *when no exception is raised*. It has never meant exceptions became free. ## What is still expensive Raising remains one of the more costly things Python does, and none of that changed: - The exception object is instantiated like any other object. - A traceback is built and extended with a frame entry as the exception propagates. - Frames are unwound, running `finally` blocks and context-manager exits on the way. - The handler's matching logic runs for each `except` clause until one matches. Measure it and a loop that raises and catches on every iteration is several times the cost of a loop that performs a plain conditional check. So the practical rule is about *frequency*, not about style: EAFP — attempt the operation and catch the failure — is the cheaper shape when failures are rare, because the guard costs nothing. LBYL — test first — wins when the "exceptional" case fires on a large share of iterations, at which point it is not exceptional at all. ## Where this bites in real code Consider a document-conversion queue whose worker loops over pages and wraps each conversion in a broad `try` that quietly continues on failure. Two things are true at once, and candidates often conflate them. First, the *performance* worry that once motivated hoisting the `try` outside the loop is gone: since 3.11 the guard itself costs nothing per page. Second, and far more important, the *correctness* problem is untouched and has nothing to do with the language version. A swallowed exception means malformed pages are silently converted to nothing: the queue reports success, output is short, and the failure surfaces days later as a user complaint rather than as an alert. The fixes are the ordinary ones — catch the narrowest exception type you actually expect, log with the traceback attached rather than discarding it, count failures as a metric, and re-raise anything you did not mean to handle. "Zero-cost exceptions" is permission to guard the code you should be guarding; it is not permission to swallow. A second real consequence is code size. The exception table has to be stored, so guarded code objects carry a little more data and `.pyc` files grow slightly. That is a memory cost, not a time cost, and it is trivial next to the per-iteration cost it replaced. ## Answering it well A strong answer names the version (3.11), states what disappeared (per-entry setup and teardown instructions on the block stack), states what replaced it (a compiled table consulted only during unwinding), and finishes with the consequence that actually changes decisions: guarding is free, raising is not, so the shape of your error handling should follow how often the error really happens. Mentioning that this landed alongside the specializing interpreter in the same release, as part of the same performance push, shows you understand it as an interpreter change rather than a syntax one — the syntax of `try`/`except` did not change at all.
- If entering a try block emits no setup instruction, where does the interpreter find the handler?In an exception table compiled alongside the bytecode and stored with the code object. It maps ranges of instruction offsets to a handler offset and the stack depth to restore. The interpreter consults it only while unwinding a raised exception, walking outward through frames until a range covers the current offset.
- Does this make exceptions a reasonable control-flow mechanism in a hot loop?Only when the exception is rare. The guard is free, but raising still allocates an exception, builds a traceback and unwinds frames, which costs several times a plain conditional check. If the branch fires on a large share of iterations, test first; if it fires occasionally, attempting and catching is both cheaper and more readable.
- What is the cost that did increase?Two things. Unwinding got slightly dearer, because finding a handler now means searching a table instead of reading an entry that was pushed on entry. And code objects carry the table, so guarded code uses a little more memory and slightly larger .pyc files. Both are small next to the per-entry cost that was removed.
saying these in an interview costs you the question
- Says exceptions became free to raise in 3.11
- Thinks a try block still costs on every loop iteration
- Claims the change required new syntax or a flag
- Treats a broad except that passes as cheap and therefore fine
- Believes tracebacks are no longer built
- Says the change only applies to code compiled with optimizations