skip to content

In what order does contextlib.ExitStack unwind the cleanups it registered?

level: juniorimportance: should knowfreq 30%

answer

  1. It is a stack, not a queue
  2. Think nested with statements, unwound
  3. Last registered resource dies first
  4. A failing cleanup does not skip the rest
  5. close() unwinds early and empties it

basics

~10 s

In reverse order of registration: last registered, first run. It is a stack, so it unwinds exactly as equivalent nested with statements would, closing the most recently entered resource first.

solid answer

~40 s

`contextlib.ExitStack` is literally a stack of cleanup callbacks. Every `enter_context()`, `callback()` and `push()` call pushes one entry; when the `with` block ends, the stack's `__exit__` pops entries and runs them one at a time, so unwinding is last-in-first-out. That mirrors nested `with` statements, which matters because later resources often depend on earlier ones - a cursor must be closed before the connection that produced it. If a cleanup raises, the remaining ones still run; the cleanup error then propagates with any original block exception preserved as its `__context__`. Calling `close()` on the stack does the same unwinding immediately and leaves the stack empty, which is how you use one outside a `with` block.

code

python · 11 lines
python
from contextlib import ExitStack

with ExitStack() as stack:
    for name in ("first", "second", "third"):
        stack.callback(print, "closing", name)
    print("body runs")

# body runs
# closing third
# closing second
# closing first

go deeper

for a junior

Recall the one-line rule: registrations push, exit pops, so the last resource registered is the first one cleaned up - the same order nested with statements use.

for a middle

Be ready to explain why reverse order is required, using a dependent pair such as a connection and something derived from it, and to say what close() does when the stack is not used as a with block.

for a senior

Show you know the failure path: every remaining cleanup still runs when one raises, only one exception escapes, and the original body error survives as its context. Say how you would keep a noisy cleanup from masking a real error.

for a principal

Own the lifetime question. A stack scoped to a request is safe; one held for the process lifetime pins every callback and bound argument it was ever given, so decide deliberately where a stack is created, who closes it, and how that is enforced in review.

`contextlib.ExitStack` exists so that a **variable** number of cleanups can be managed with the same guarantees a `with` statement gives a fixed one. The ordering rule is the heart of the design: **cleanups run in reverse registration order, last in first out.** ## Why it is a stack at all Every registration method pushes exactly one entry onto an internal list: - `enter_context(cm)` calls the manager's `__enter__`, pushes its `__exit__`, and returns whatever `__enter__` returned. - `push(exit)` pushes an `__exit__`-style callable **without** calling `__enter__`. - `callback(fn, *args, **kwargs)` pushes a plain function that will be called with those arguments and never sees exception information. When the `ExitStack` itself is exited - either because the enclosing `with` block ends or because you call `close()` - it pops entries off the end of that list and invokes them. Popping from the end is what makes the order reverse. ## Why reverse order is the only correct order Resources acquired later frequently depend on resources acquired earlier. A cursor is derived from a connection; a temporary file lives inside a temporary directory; a lock is taken after the object it protects has been created. If cleanup ran in registration order, you would tear down the connection while a cursor still referenced it, or remove the directory while a file inside it was still open. Nested `with` statements have always unwound inner-to-outer for the same reason, and `ExitStack` deliberately reproduces that semantics for the dynamic case, so refactoring a nested `with` into a stack does not change behaviour. ```python from contextlib import ExitStack with ExitStack() as stack: for name in ("first", "second", "third"): stack.callback(print, "closing", name) print("body runs") # body runs / closing third / closing second / closing first ``` ## What happens when the block raises If the body raises, the stack still unwinds completely; each registered exit callback that takes exception details receives `(exc_type, exc_value, traceback)`, and callbacks registered with `callback()` are simply called with their bound arguments. Cleanup is not skipped because the block failed - that is the entire point of the construct. ## What happens when a cleanup itself raises Every remaining cleanup still runs. Suppose three callbacks are registered and the middle one raises: the third runs, the middle raises, and the first still runs afterwards. One cleanup error then propagates out of the `with` statement. If the block body had already raised, the original exception is preserved as the `__context__` of the propagating cleanup error, so the traceback shows both - "during handling of the above exception, another exception occurred". Do not rely on more than that: if several cleanups raise, only one error surfaces, so cleanup code that can plausibly fail should log or swallow its own failure rather than letting it mask a sibling's. ## Unwinding early with close() The stack does not have to be used as a `with` block. `ExitStack()` can be created, populated over time, and unwound by calling `close()`. `close()` runs the same reverse-order unwinding and leaves the stack empty, so a second `close()` is a no-op. That is what makes an `ExitStack` usable as a long-lived member of an object: an object that owns a stack can register cleanups in its own setup path and call `stack.close()` in its shutdown path. The flip side is that a long-lived stack **holds strong references** to every callback and to every argument bound into it. A process that keeps one stack for its whole run and registers a cleanup per unit of work - one per digest batch, say - never releases any of them, and memory grows without bound even though each individual resource looks small. The fix is to scope the stack to the unit of work, or to call `close()` at the end of each cycle and start a fresh one. ## The mental model to carry into the interview Think of `ExitStack` as the `with` statement's nesting turned into runtime data. Whatever a chain of nested `with` statements would do - the order of entry, the order of exit, the guarantee that exiting happens on both the success and the failure path - the stack does the same, but with the shape decided while the program runs rather than when it was written.

  • How do you unwind an ExitStack without using it as a with block?
    Call its `close()` method. `close()` performs the same reverse-order unwinding, running every registered cleanup, and leaves the stack empty so a second call does nothing. That is how a stack held as an attribute of a long-lived object is torn down from that object's own shutdown method rather than from a `with` statement.
  • If two cleanups on the same ExitStack both raise, how many exceptions reach the caller?
    One. All the cleanups still run - a failure does not abort the unwinding - but only a single exception propagates out of the block, so the other failure is lost. If a cleanup can realistically fail and that failure matters, catch and log it inside the callback instead of letting it escape and shadow another error.

Registering cleanups is like stacking plates: you take the top one off first, because the plate underneath is what was holding it up.

saying these in an interview costs you the question

  • Says cleanups run in registration order
  • Assumes a failing cleanup skips the remaining ones
  • Thinks the stack must be used as a with block
  • Believes exiting the block leaves resources open
  • Claims every failing cleanup's exception reaches the caller

context