A normal with statement works best when you know the resources before the block starts:
with open("input.csv", "rb") as source, open("output.csv", "wb") as target:
...The structure is clear because both files are known in advance. Python enters each context manager and guarantees that their exit logic runs when the block finishes, including when an exception leaves the block.
Some programs do not know that resource set ahead of time. A command may receive an arbitrary list of input files. A pipeline may acquire an optional lock only for one mode. A setup phase may need to register a plain cleanup function after each successful step. Writing deeply nested with statements is impossible when the number of resources is determined at runtime, while manual try/finally code becomes easy to get wrong as the setup grows.
contextlib.ExitStack solves this specific problem. It lets code build the equivalent of nested context managers programmatically. Each successful acquisition registers its cleanup action with the stack. When the stack closes, those actions run in reverse order.
The useful mental model is:
acquire A -> register cleanup A
acquire B -> register cleanup B
acquire C -> register cleanup C
close stack:
cleanup C -> cleanup B -> cleanup AThis article develops that model from opening a dynamic set of files to mixed cleanup callbacks, transactional acquisition, exception behavior, and the cases where an ordinary with statement remains simpler.
Start with a runtime-defined set of files
Suppose a merge command receives any number of input paths:
paths = ["part-01.txt", "part-02.txt", "part-03.txt"]You want every successfully opened file to remain available during the merge and to close reliably afterward.
ExitStack.enter_context() does exactly that:
from contextlib import ExitStack
paths = ["part-01.txt", "part-02.txt", "part-03.txt"]
with ExitStack() as stack:
files = [
stack.enter_context(open(path, "rt", encoding="utf-8"))
for path in paths
]
for file in files:
print(file.readline().rstrip())enter_context() calls the context manager’s __enter__() method immediately, stores its __exit__() method for later, and returns whatever __enter__() returned. For a file object, that return value is the opened file.
The important property appears if opening a later file fails. Imagine the first two files open successfully but the third path does not exist. Their exit callbacks are already registered, so leaving the with ExitStack() block closes the first two files automatically.
You do not need a separate cleanup branch for “opened two out of three files.” Cleanup grows incrementally with successful acquisition.
Think of ExitStack as dynamic nesting
A fixed group of context managers can be written directly:
with first_resource() as first:
with second_resource() as second:
use(first, second)The second resource exits before the first because nested blocks unwind from the inside out.
ExitStack preserves the same last-in, first-out ordering:
from contextlib import ExitStack
with ExitStack() as stack:
first = stack.enter_context(first_resource())
second = stack.enter_context(second_resource())
use(first, second)Conceptually, the stack is building the nesting structure at runtime.
Reverse-order cleanup matters when later resources depend on earlier ones. For example, a temporary output stream may depend on a directory handle or a lock acquired before it. Releasing the newest resource first often mirrors the dependency order used during setup.
Do not rely on reverse order merely because it is convenient. If one cleanup genuinely depends on another resource still being alive, make that relationship explicit in the acquisition order and document it.
Use ExitStack when acquisition happens in a loop
The clearest use case is a loop whose length is not known until runtime.
Consider a function that counts lines across several files:
from contextlib import ExitStack
def count_lines(paths):
with ExitStack() as stack:
files = [
stack.enter_context(open(path, "rt", encoding="utf-8"))
for path in paths
]
return sum(1 for file in files for _ in file)The function has one lifetime boundary: all files belong to the with ExitStack() block.
Without ExitStack, code often drifts toward this shape:
files = []
try:
for path in paths:
files.append(open(path, "rt", encoding="utf-8"))
return sum(1 for file in files for _ in file)
finally:
for file in reversed(files):
file.close()This manual version can be correct, but it duplicates the resource-management protocol that context managers already implement. It also becomes more complicated when the resource list contains different kinds of context managers rather than only files.
Use explicit try/finally when it communicates a small custom lifetime more directly. Use ExitStack when the number or type of managed resources is dynamic enough that manual cleanup bookkeeping becomes the main complexity.
Register ordinary cleanup functions with callback()
Not every resource exposes a context manager.
Suppose setup creates a temporary registration that must later be removed:
registration = register_worker("thumbnailer")If cleanup is an ordinary function such as unregister_worker(registration), register it with ExitStack.callback():
from contextlib import ExitStack
with ExitStack() as stack:
registration = register_worker("thumbnailer")
stack.callback(unregister_worker, registration)
run_worker(registration)The callback runs when the stack closes, in the same reverse registration order as context-manager exits.
This lets one stack manage mixed cleanup:
from contextlib import ExitStack
with ExitStack() as stack:
lock = stack.enter_context(acquire_lock())
file = stack.enter_context(open("jobs.txt", "rt", encoding="utf-8"))
registration = register_worker("thumbnailer")
stack.callback(unregister_worker, registration)
process_jobs(file, lock, registration)There is an important difference between callback() and a context manager’s __exit__() method: callbacks registered with callback() are not passed exception details, so they cannot suppress the exception leaving the block.
That limitation is useful when the cleanup action is simply “close this handle,” “delete this temporary registration,” or “release this reservation” and should not participate in exception policy.
Register cleanup immediately after acquisition
A reliable pattern is:
acquire resource
register its cleanup
move to the next acquisitionDo not acquire several resources first and register their cleanup later.
Risky:
first = acquire_first()
second = acquire_second()
stack.callback(release_first, first)
stack.callback(release_second, second)If acquire_second() raises, first exists but its cleanup has not yet been registered.
Prefer:
first = acquire_first()
stack.callback(release_first, first)
second = acquire_second()
stack.callback(release_second, second)Now every completed acquisition has a cleanup path before the next operation can fail.
Context managers naturally encourage this pattern because enter_context() both enters the resource and registers its exit method as one operation from the caller’s perspective.
Use pop_all() for all-or-nothing acquisition
Sometimes setup should clean itself up on failure but transfer ownership to the caller after every resource has been acquired successfully.
ExitStack.pop_all() supports this pattern. It moves the current exit callbacks into a new ExitStack without running them.
For example:
from contextlib import ExitStack
def open_all(paths):
with ExitStack() as stack:
files = [
stack.enter_context(open(path, "rt", encoding="utf-8"))
for path in paths
]
owner = stack.pop_all()
return files, ownerIf any open() fails, the original stack unwinds the files that were already opened.
If every open() succeeds, pop_all() transfers their cleanup callbacks to owner. Leaving the original with statement no longer closes those files.
The caller now owns the lifetime:
files, owner = open_all(paths)
try:
consume(files)
finally:
owner.close()This is a form of transactional acquisition:
during setup:
failure -> roll back acquired resources
after successful setup:
transfer cleanup responsibilitypop_all() does not make the resources transactional in the database sense. It only transfers the registered cleanup actions. Any external side effect that cannot be reversed remains the application’s responsibility.
Understand what close() does with exceptions
When a stack exits because an exception is leaving its with block, registered context-manager exit methods receive exception information through the normal context-manager protocol. That means a context manager entered with enter_context() can suppress or replace an exception just as it could in a directly nested with statement.
By contrast, calling stack.close() explicitly means there is no active exception being passed into the stack’s exit sequence. The registered context-manager exits are invoked as a normal close.
This distinction matters if you use context managers whose __exit__() methods implement exception policy rather than only cleanup.
For most resource-management code, prefer:
with ExitStack() as stack:
...over manually creating a stack and remembering to call close(). The with form ensures that an exception from the body participates in normal context-manager unwinding.
Also note that an ExitStack does not promise cleanup merely because the stack object becomes unreachable. Cleanup happens when the stack is closed explicitly or by leaving its with statement. Do not treat garbage collection as the lifetime mechanism.
Cleanup failures can change the exception that escapes
Cleanup code can fail too.
Because ExitStack behaves like nested context managers, the exception seen by an outer exit callback can reflect what an inner exit callback did. An inner context manager may suppress the current exception or raise a different one, and the remaining outer callbacks then observe that updated exception state.
This is another reason to keep cleanup routines narrow and dependable.
Suppose the main operation raises ProcessingError, but the newest resource’s cleanup raises CleanupError. Depending on the exit methods involved, the final exception may no longer be the original processing failure.
Avoid cleanup functions that perform unrelated work such as network calls, expensive validation, or new business operations unless that behavior is genuinely required. Cleanup paths should focus on releasing or reverting what was acquired.
When cleanup itself can fail meaningfully, decide how that failure should be recorded and tested instead of assuming the original exception will always remain the only visible error.
Do not hide resource ownership behind helper functions
ExitStack makes lifetime management flexible, but that flexibility can obscure ownership if a helper quietly registers resources in a stack supplied by unrelated code.
For example:
def prepare_report(stack, path):
file = stack.enter_context(open(path, "rt", encoding="utf-8"))
...This can be reasonable when the calling API clearly says that stack owns the report resource. It becomes difficult to maintain if helpers register callbacks without making that ownership transfer obvious.
A reader should be able to answer:
Who acquired this resource?
Which stack owns its cleanup?
When does that stack close?If those answers require tracing through many layers, a dedicated context manager may communicate the lifetime better.
ExitStack is a mechanism for composing lifetimes, not a substitute for designing clear ownership boundaries.
Prefer a dedicated context manager for a stable abstraction
If a sequence of acquisitions always occurs together, wrap it in a higher-level context manager instead of rebuilding the same ExitStack logic at every call site.
For example, if every report export always acquires the same lock, opens the same metadata file, and reserves the same workspace, those resources form a stable abstraction.
The implementation may still use ExitStack internally:
from contextlib import contextmanager, ExitStack
@contextmanager
def report_workspace(path):
with ExitStack() as stack:
lock = stack.enter_context(acquire_lock(path))
metadata = stack.enter_context(open_metadata(path))
reservation = reserve_workspace(path)
stack.callback(release_workspace, reservation)
yield lock, metadata, reservationCallers then see the domain concept rather than the cleanup machinery:
with report_workspace(report_path) as workspace:
export_report(workspace)This improves maintainability when the resource group is stable because ownership rules live in one place.
Use ExitStack directly at the call site when dynamic composition is itself the important behavior.
Async code needs AsyncExitStack
ExitStack manages synchronous context managers and synchronous cleanup callbacks.
When resources use async with, use contextlib.AsyncExitStack. It can compose asynchronous context managers and asynchronous cleanup callbacks, and it uses aclose() rather than close() for explicit asynchronous unwinding.
A simplified shape is:
from contextlib import AsyncExitStack
async with AsyncExitStack() as stack:
connection = await stack.enter_async_context(open_connection())
...Do not put an asynchronous cleanup coroutine into ExitStack.callback(). A synchronous ExitStack does not await it.
If an async operation also owns ordinary synchronous context managers, AsyncExitStack can combine both styles. Keep the choice driven by the resources that actually need asynchronous entry or exit.
Common mistakes
Using ExitStack when the resource list is fixed
This is valid:
with ExitStack() as stack:
source = stack.enter_context(open("in.txt", "rt", encoding="utf-8"))
target = stack.enter_context(open("out.txt", "wt", encoding="utf-8"))But a normal with statement is shorter and more immediately readable when the resources are fixed:
with open("in.txt", "rt", encoding="utf-8") as source, \
open("out.txt", "wt", encoding="utf-8") as target:
...Dynamic machinery should solve a dynamic problem.
Registering cleanup too late
A resource is vulnerable to leaks between acquisition and cleanup registration. Register immediately.
Assuming callback() can suppress an exception
callback() does not receive exception details and cannot suppress the exception from the managed block. Use a real context manager when cleanup needs to participate in exception handling.
Forgetting who owns a stack returned by pop_all()
After pop_all(), the new stack owns the callbacks. Something must eventually close it. Treat that ownership transfer as seriously as returning an open file or socket.
Relying on garbage collection
ExitStack callbacks are not implicitly invoked just because the stack is garbage-collected. Use with, close(), or aclose() for the asynchronous variant.
When ExitStack is the right tool
Use ExitStack when resource lifetime is scoped but the resource set is determined dynamically. Typical signs include:
- context managers acquired inside a loop;
- optional context managers selected by runtime conditions;
- several different resource types that need one cleanup boundary;
- plain cleanup callbacks mixed with context managers;
- setup that should automatically unwind partial acquisition;
- successful setup that needs an explicit ownership transfer with
pop_all().
Prefer an ordinary with statement when the resource set is small and known in advance. Prefer a dedicated context manager when a stable group of resources represents one reusable domain abstraction. Prefer direct try/finally when one custom cleanup action is clearer than introducing a stack.
The goal is not to minimize the number of lines. It is to make the lifetime rule obvious and correct.
Conclusion
contextlib.ExitStack turns resource cleanup into a runtime-built stack. Each successful acquisition registers what must happen later, and closing the stack unwinds those actions in reverse order.
That model is especially useful when the number of files, locks, registrations, or other resources depends on input rather than source-code structure. enter_context() composes existing context managers, callback() adds ordinary cleanup functions, and pop_all() supports all-or-nothing acquisition followed by ownership transfer.
Use it where resource composition is genuinely dynamic. For fixed resources, ordinary with statements remain easier to read. The practical rule is simple: acquire one resource, register its cleanup immediately, and keep the stack’s ownership boundary obvious.