Why use contextlib.ExitStack instead of a with statement listing fixed managers?
answer
- The count is data, not source
- One loop, one enter_context per resource
- What if setup fails halfway through?
- Flattens a nested with staircase
- Conditional entry without duplicating the body
basics
~20 sBecause a with statement has to name its managers when the code is written. ExitStack lets you enter a number of resources decided at runtime - a loop over a list - while keeping the same guaranteed, reverse-order cleanup.
solid answer
~40 sA `with` statement fixes the set of managers at compile time; `contextlib.ExitStack` moves that decision to runtime. You open the stack once, then call `stack.enter_context(cm)` inside a loop for each resource, and every one entered so far is cleaned up when the block ends. Crucially the guarantee holds **during** setup too: if the fortieth `enter_context()` raises, the thirty-nine already entered are unwound in reverse order before the exception leaves the block, so there is no partially-open set to leak. It also flattens deeply nested `with` staircases and lets you enter a manager conditionally - a feature flag decides whether a profiler or a transaction is in play - without duplicating the body. When the count is known and small, a plain `with` is still clearer.
code
python · 13 linesfrom contextlib import ExitStack
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as tmp:
paths = [Path(tmp, f"case_{i}.txt") for i in range(340)]
with ExitStack() as stack:
files = [stack.enter_context(p.open("w", encoding="utf-8")) for p in paths]
for index, handle in enumerate(files):
handle.write(f"case {index}\n")
print(all(handle.closed for handle in files))
# Truego deeper
Know the headline: use it when the number of resources is decided while the program runs, and remember that enter_context gives you back the same thing an as clause would bind.
Explain the mechanics - enter_context calls enter, registers exit, returns the value - and show the mid-setup guarantee: a failure on the fortieth resource still closes the thirty-nine already entered.
Demonstrate judgement about when not to use it, and name the lifetime trap: a stack that outlives the work registers cleanups forever and never unwinds, so memory climbs while every individual resource looks harmless.
Frame it as a resource-ownership convention: decide where stacks are created and closed in the codebase, whether acquisition helpers hand back resources or a stack, and how that rule is checked, so no module invents its own half-correct rollback loop.
The `with` statement is a **static** construct: the managers it drives are written into the source. That is perfect when you know there are exactly two files and one lock, and useless when the number is data. ## The concrete shape of the problem Consider a regression harness that has to open one fixture file per case in a 340-case pack, write into all of them, and be certain every handle is closed even if case 200 fails to open. You cannot write 340 nested `with` statements, and you cannot write `with open(a), open(b), ...` because the list is a runtime value. Recursion works and is horrible. A manual `try/finally` around a list of handles works but you must write the unwinding loop yourself, get its order right, and make it robust when one close fails. `contextlib.ExitStack` is the standard answer: ```python from contextlib import ExitStack with ExitStack() as stack: files = [stack.enter_context(p.open("w", encoding="utf-8")) for p in paths] for index, handle in enumerate(files): handle.write(f"case {index}\n") ``` `enter_context()` does three things per resource: calls the manager's `__enter__`, registers its `__exit__` on the stack, and returns the value `__enter__` produced - which is why the list comprehension collects usable file objects rather than context managers. ## The guarantee that makes it more than sugar The interesting property is not the tidy syntax, it is what happens **mid-setup**. Suppose the pack has 340 cases and the 200th path is on a full filesystem, so `open()` raises. The exception propagates out of the comprehension, which propagates out of the `with` block - and on the way out, the stack unwinds the 199 handles already registered, in reverse order. There is no window in which a partially-built set of resources is orphaned. Writing that by hand means an explicit `try` around the acquisition loop with a cleanup loop in its `except`, which is exactly the code people get wrong. ## The other three reasons it shows up **Flattening.** Three or four nested `with` statements indent the real work off the page. Rewriting them as `a = stack.enter_context(...)` lines keeps the body at one level and makes the ordering explicit as data rather than as indentation. **Conditional acquisition.** A manager that should only be entered sometimes forces either a duplicated body or a no-op placeholder. With a stack, `if profiling: stack.enter_context(profiler)` is a single line and the body is written once. **Heterogeneous cleanup.** A stack does not only take context managers. Alongside `enter_context()` you can register a plain function with `callback()`, so a legacy object with a `disconnect()` method and a modern context manager can be torn down by the same mechanism, in one interleaved order. ## When not to reach for it If the resources are known, few, and always the same, a plain `with` statement naming them is clearer and gives better tracebacks - the manager appears in the source at the point it is entered. `ExitStack` costs a little indirection and hides the resource set from a casual reader, so it earns its place when the set is dynamic, conditional, or long. Using one for a single file is noise. ## A trap worth naming An `ExitStack` cleans up when it exits, not when the objects it holds go out of scope. If you keep a stack alive across many units of work and register a cleanup per unit - one per digest batch, for instance - the stack accumulates every callback and every argument bound into it, and memory climbs steadily for the life of the process. Scope the stack to the unit of work, or call `close()` at the end of each cycle. The construct's guarantee is "everything registered runs when I unwind"; if you never unwind, nothing runs.
- What does enter_context() return, and why does that matter in a comprehension?It returns whatever the manager's `__enter__` returned - the file object, the connection, the lock - not the manager itself. That is what lets `[stack.enter_context(open(p)) for p in paths]` build a list of usable handles. It is the same value the `as` clause of a `with` statement would have bound.
- When would you still prefer a plain with statement over an ExitStack?When the resources are fixed, few and always present. A `with` naming its managers is more readable, keeps each acquisition visible at the point it happens, and gives a clearer traceback. A stack earns its indirection when the set is dynamic, conditional, or long enough that nesting hurts.
- Can an ExitStack manage something that is not a context manager?Yes. `callback(fn, *args, **kwargs)` registers any plain callable to be invoked at unwind time with those arguments, so a legacy object exposing only `disconnect()` or `release()` is torn down by the same stack, in the same reverse order, interleaved correctly with real context managers.
saying these in an interview costs you the question
- Thinks a with statement can take a runtime list of managers
- Says resources entered before a setup failure leak
- Believes enter_context returns the manager, not its __enter__ value
- Uses an ExitStack for a single fixed resource
- Assumes it only works with file objects