skip to content

Why does a loop that raises and catches an exception on nearly every iteration get slow?

level: seniorimportance: should knowfreq 40%

answer

  1. The happy path is not what is slow
  2. Something is allocated per frame unwound
  3. Message arguments are evaluated eagerly
  4. Cost scales with distance to the handler
  5. Count the handler hit rate first

basics

~20 s

Because the raise itself is the expensive part: each one instantiates the exception, evaluates its message argument, allocates a traceback entry for every frame it unwinds through, and is matched against the except clauses. Entering the try is free; raising is not.

solid answer

~50 s

Since 3.11 entering a `try` costs nothing, so the cost is entirely in the raise. Every iteration pays for constructing the exception instance (including eagerly evaluating any f-string message), for a traceback object linked onto `__traceback__` for each frame between the raise site and the handler, for the exception-table search in each of those frames, and for the subclass match against every `except` clause. Even caught in the same frame that costs several times the successful lookup or conversion it replaced, and a raise from a few calls deep with a formatted message runs into the hundreds of nanoseconds or beyond — so a handler that fires on ninety per cent of iterations can dominate the loop. The fix is to make raising rare again: precompute or restructure so the common case succeeds, keep the raise site close to its handler, and stop building expensive messages nobody reads. Confirm with `timeit` and by counting how often the handler actually fires.

code

python · 21 lines
python
import timeit

SETUP = """
def leaf():
    raise ValueError("bad budget")

def descend(n):
    if n:
        return descend(n - 1)
    return leaf()

def run(depth):
    try:
        descend(depth)
    except ValueError:
        pass
"""

for depth in (0, 5, 20):
    best = min(timeit.repeat(f"run({depth})", SETUP, repeat=5, number=50_000))
    print(depth, f"{best / 50_000 * 1e9:.0f} ns/raise")

go deeper

for a junior

Remember the direction of the cost: guarding is free, raising is not. If a handler runs on most iterations, the exception has become normal control flow and that is the thing to question.

for a middle

Enumerate the four costs — instance construction, eager message evaluation, a traceback entry per frame unwound, and the subclass match per except clause — and explain why depth of the call chain changes the number.

for a senior

Show the diagnosis before the fix: count the handler hit rate, time both paths with equal loop counts, then restructure so the common case succeeds. Be ready to reject reusing an exception instance and to explain why hoisting the guard changes semantics, not speed.

for a principal

Treat a constantly-firing handler as a data-contract problem, not a micro-optimization: decide where malformed input gets normalized once, what the silent fallback does to downstream numbers, and what your codebase's rule is for exceptions as control flow.

## The scenario A ticket-triage bot enriches every incoming ticket by walking a 17-service dependency graph and reading each service's latency budget out of a config map, converting it with `float()` inside a `try/except ValueError` and falling back to a default when the value is missing or malformed. In production most services never published a budget, so the conversion raises on roughly nine of every ten nodes, seventeen nodes per ticket, thousands of tickets a minute. The enrichment step is the slowest thing the bot does — and, separately, the fallback default introduces a floating-point rounding drift that makes the aggregate priority score irreproducible. Both symptoms come from the same design: an exception used as the normal control flow. ## What a raise actually pays for Since CPython 3.11 the `try` itself is free — no bytecode executes on entry — so every microsecond is in the raise. Four costs stack up, and being able to enumerate them is what the question tests. **Constructing the exception.** `raise ValueError(...)` is a class call: allocate an instance, run `__init__`, store the `args` tuple. Anything you pass is evaluated *first*, eagerly. A message built with an f-string that formats a dictionary or repr-s a large object costs whatever that formatting costs, on every raise, even when the handler discards the exception without ever rendering it. **Building the traceback.** As the exception propagates, CPython allocates a traceback entry for each frame it leaves and links it onto the exception's `__traceback__`. This is the cost that scales: a raise handled in the same frame is far cheaper than one thrown from four helper calls deep, because the deeper one allocates and links four times as much. If your hot loop calls a helper that calls a validator that raises, you pay for the whole chain on every iteration. **Unwinding.** For each frame, the interpreter searches that frame's exception table for a range covering the current instruction offset, restores the value stack and either jumps to a handler or unwinds further. This is the price of the zero-cost design: entry is free, propagation does the work. **Matching.** The instance is tested against each `except` clause in order until one matches, and each test is a subclass check. Several clauses mean several checks. If a handler raises or re-raises, implicit chaining also links the previous exception onto `__context__`, adding another walk. ## Orders of magnitude Do not quote a memorized number; quote a ratio and then measure. A raise-and-catch round trip lands in the hundreds of nanoseconds to about a microsecond on a current CPython, while the successful dictionary hit or `float()` conversion it stands in for is tens of nanoseconds. That is roughly an order of magnitude or more per iteration — invisible when the handler fires on one iteration in a thousand, and the dominant cost when it fires on nine in ten. This is precisely why the same idiom is excellent on a happy path and terrible as control flow. ## Confirming it rather than assuming it Two cheap confirmations, both of which an interviewer wants to hear before any fix. First, **count**: increment a counter in the handler and log the hit rate. "The handler fires on 91% of iterations" turns an opinion into a fact, and it is the number that decides whether this is worth touching at all. Second, **measure** with `timeit`: time the loop body against a version where the input always converts cleanly, using the same loop count and taking the minimum of several repeats. The gap between those two figures is the exception's price on your build and your machine. ## Fixing it The fix is to make the raise rare again, not to make it cheaper. In the bot, that means normalizing the config once at load time so every node has a valid budget, or resolving the default in a single pass rather than per lookup — the common case then succeeds and the handler goes back to being an exception. Where you cannot restructure, three smaller levers help: keep the raise site close to the handler so fewer frames are unwound; stop building expensive messages, or defer them to code that actually formats the error; and collapse a chain of narrow `except` clauses if the ordering is only there to satisfy a match that always falls through to the last one. ## Two anti-fixes to name and reject **Reusing a single pre-built exception instance** to avoid the allocation is a real suggestion you will hear and it is wrong: the instance's `__traceback__` is overwritten and accumulated on each raise, so it leaks frames and reports the wrong origin, and it is not safe to share across threads. **Hoisting the `try` outside the loop** saves nothing, because entry was already free, and it changes the semantics — the first failure now ends the loop instead of being handled per item. Finally, note the correctness half of the story. A handler that fires on most iterations is usually hiding a data problem, and here it was: the silent default is what produced the rounding drift in the aggregate score. Performance was the symptom that made someone look.

  • Why does raising from four calls deep cost more than raising in the handler's own frame?
    Because a traceback entry is allocated and linked onto the exception's `__traceback__` for every frame the exception leaves, and each of those frames also has its exception table searched before unwinding continues. The per-raise cost therefore grows with the distance between the raise site and the handler, which is a concrete argument for catching close to where the failure happens in a hot path.
  • A colleague proposes hoisting one pre-built exception instance out of the loop to avoid allocating. What do you say?
    Reject it. Raising the same instance repeatedly overwrites and accumulates its `__traceback__`, so the object reports a misleading origin and holds frames alive, and sharing one mutable exception across threads is unsafe. The allocation is not where the money is anyway — the traceback and unwinding are. Fix the frequency of raising instead.
  • Does building the message with an f-string cost anything if nobody ever prints the exception?
    Yes. The argument to the exception class is an ordinary expression, evaluated before the instance exists, so the formatting happens on every raise regardless of whether the handler renders it. In a loop that raises constantly, an f-string that reprs a large structure can cost more than the raise machinery itself.

saying these in an interview costs you the question

  • Blames the try block rather than the raises
  • Says exceptions cost the same as a return
  • Suggests reusing one exception instance to save allocation
  • Hoists the try out of the loop for speed
  • Ignores that the message argument is evaluated eagerly
  • Optimizes without counting how often the handler fires

context