skip to content

How does contextlib.ExitStack.pop_all() let a factory return resources it opened?

level: seniorimportance: should knowfreq 28%

answer

  1. Roll back during setup, hand off after
  2. Move the callbacks, empty the original
  3. The original exit becomes a no-op
  4. A factory that returns open resources
  5. Someone must still close what you return

basics

~20 s

pop_all() moves every registered cleanup onto a new ExitStack and empties the original, so the original's exit becomes a no-op. Setup failures still roll back; once setup succeeds, the caller receives the new stack and owns the teardown.

solid answer

~40 s

It converts an all-or-nothing setup block into a successful hand-off. Inside `with ExitStack() as stack:` you enter each resource with `enter_context()`, so any failure part-way through unwinds everything already acquired. On the last line - once you know setup succeeded - `stack.pop_all()` transfers the registered callbacks to a **new** `ExitStack` and leaves the original empty, so when the `with` block ends it closes nothing. You return the resources together with that new stack, and the caller closes it later, typically with its own `with`. This is the standard way to write a factory that opens a runtime number of resources: without `pop_all()` the factory would close everything the instant it returned, and hand-rolling the rollback loop instead is where partial-failure leaks come from.

code

python · 18 lines
python
from contextlib import ExitStack
from io import StringIO

def open_digest_buffers(count):
    with ExitStack() as stack:
        buffers = [stack.enter_context(StringIO()) for _ in range(count)]
        return buffers, stack.pop_all()

buffers, cleanup = open_digest_buffers(3)
print([buf.closed for buf in buffers])

with cleanup:
    buffers[0].write("digest")

print([buf.closed for buf in buffers])

# [False, False, False]
# [True, True, True]

go deeper

for a junior

Know the shape rather than the details: a with-managed stack normally closes everything on the way out, and pop_all is the escape hatch used when a function needs to return resources still open.

for a middle

Explain the two halves - rollback while acquiring, hand-off once acquisition succeeded - and be able to say why a factory without pop_all returns already-closed resources.

for a senior

Show you have used it: place the call on the last line, explain that the emptied original exits as a no-op, and name the ownership obligation you have just created for the caller.

for a principal

Decide the codebase convention for transferred ownership - whether factories return stacks at all versus wrapping resource groups in their own context manager - and how you keep long-lived stacks from becoming an unbounded store of pending cleanups.

Everything a `with ExitStack()` block registers is torn down when the block ends. That is exactly what you want while you are *building* a set of resources and exactly what you do not want when you are *returning* it. `pop_all()` is the seam between the two. ## The problem, concretely An email-digest sender needs, per run, a variable number of per-tenant output buffers plus a couple of shared handles. A factory should open them all or none: if the ninth fails, the eight already open must be released before the error escapes. Once all of them are open, the caller wants to use them - so the factory must **not** close them on the way out. Write only the first half and you get correct rollback but a useless return value; write only the second half and a mid-setup failure leaks whatever was already open. ## What pop_all() actually does `pop_all()` creates a new `ExitStack`, moves the entire list of registered exit callbacks onto it in the same order, and clears the original stack. It returns the new stack. Two consequences follow. First, the original stack is now empty, so its `__exit__` - which still runs when the `with` block ends - has nothing to call and is a no-op. Second, the transferred callbacks are still pending, in the same order, on an object you can return, store on `self`, or hand to another stack with `enter_context()`. ```python def open_digest_buffers(count): with ExitStack() as stack: buffers = [stack.enter_context(StringIO()) for _ in range(count)] return buffers, stack.pop_all() ``` Read the failure path first: if the third `StringIO()` raised, the two already entered are closed as the exception leaves the block, and `pop_all()` never runs. Now the success path: the tuple is evaluated - which calls `pop_all()` and empties the stack - and only then does the block exit and find nothing to do. The caller gets live buffers and a stack that owns their teardown. ## Using what you get back The returned object is an ordinary `ExitStack`, so the caller has the usual choices: `with cleanup:` around the work, `cleanup.close()` in a shutdown method, or `outer_stack.enter_context(cleanup)` to nest the whole set of resources into a larger scope. That last form is how a composite resource composes: an object's `__enter__` builds its parts on a temporary stack, calls `pop_all()`, keeps the result as an attribute, and its `__exit__` closes it. ## Why not just do it by hand The manual equivalent is a `try` around the acquisition loop, an `except` that walks a partially-built list in reverse calling the right teardown for each element, a bare `raise`, and a separate teardown path for the success case. That code has to get the order right, keep working when one teardown raises, and stay correct as the resource list grows. `pop_all()` reuses the machinery you already trust for all of it, and reduces the whole thing to one line at the point where you know you have succeeded. ## The failure mode to name in an interview Ownership transferred is ownership someone must accept. Once the factory returns, the language will not close those resources for you - the new stack is just an object, and letting it be garbage collected does **not** run its callbacks. Two habits fall out. Make the transfer visible in the signature: return the stack, or return an object whose own context-manager protocol closes it, rather than quietly stashing it somewhere. And keep the stack's lifetime tied to a unit of work: a long-lived stack that accumulates a batch's cleanups on every cycle and is never closed grows without bound, holding every buffer and every bound argument it was ever given, while each individual resource looks far too small to matter. The symptom is steadily climbing memory with no obvious owner; the cause is a stack that was never unwound. ## The one-sentence version `pop_all()` says "setup succeeded - the cleanups I registered are no longer mine", and the object it returns is the receipt the new owner must eventually redeem.

  • After pop_all(), what does the original stack do when its with block ends?
    Nothing. Its callback list was moved wholesale to the new stack, so its `__exit__` still runs but finds no entries to invoke. That is the mechanism: the transfer, not a flag or a suppressed exit, is what makes the block harmless on the success path.
  • What happens if the caller never closes the stack a factory returned?
    The resources stay open. An ExitStack is an ordinary object; garbage collecting it does not run its pending callbacks, so nothing releases what it holds. Make the obligation obvious - return the stack, or wrap the whole set in an object whose own `__exit__` closes it - rather than storing it out of sight.
  • How would you fold a returned cleanup stack into a larger scope?
    Register it on the enclosing stack with `outer.enter_context(cleanup)`. An ExitStack is itself a context manager, so entering it on another stack makes the outer scope responsible for unwinding the whole transferred group, in the right order, along with everything else that scope owns.

It is like a removal crew that will put everything back if the move fails, then hands you the keys the moment the last box is inside - after that the responsibility is yours.

saying these in an interview costs you the question

  • Thinks pop_all runs the registered cleanups
  • Expects the returned stack to close itself when collected
  • Calls pop_all before the resources are all acquired
  • Believes the original stack still closes the resources
  • Returns open resources with no way for the caller to close them

context