Managing several files, connections, locks, temporary objects, and cleanup actions can quickly produce deeply nested with statements. Python ExitStack, provided by contextlib, solves this problem by letting an application register context managers and cleanup callbacks dynamically. It is especially valuable when the number or type of resources is only known at runtime.
This guide explains how ExitStack works, why it closes resources in reverse order, how to open a variable number of files, register regular callbacks, transfer cleanup responsibility, handle partial acquisition failures, and test every exit path. It complements our articles about asyncio, tempfile, zipfile, filecmp, and zoneinfo.
The problem with dynamic resources
A normal with statement is ideal when every resource is known while writing the code:
with open("input.txt") as source, open("output.txt", "w") as target:
target.write(source.read())However, applications often receive a list of paths from configuration, discover files in a directory, or decide at runtime whether a lock, transaction, or network session is necessary. A loop that opens resources manually must also guarantee that everything already opened is closed if a later operation fails.
Your first ExitStack
from contextlib import ExitStack
paths = ["a.txt", "b.txt", "c.txt"]
with ExitStack() as stack:
files = [
stack.enter_context(open(path, encoding="utf-8"))
for path in paths
]
contents = [file.read() for file in files]
print("Every file is closed")enter_context() enters each context manager and immediately records its exit method. When the stack closes, registered exits run from the newest to the oldest. This last-in, first-out order matches nested with statements and protects dependencies between resources.
How the exit stack behaves
ExitStack stores exit functions in an internal stack. Entering a context manager adds its __exit__() method. Registering a callback adds that callable. When execution leaves the block, the stack invokes each function in reverse registration order.
This design separates acquisition from syntax. The application can build a cleanup plan while it runs, yet the final behavior remains deterministic and easy to audit.
Open a variable number of files
from contextlib import ExitStack
from pathlib import Path
def merge_files(sources, destination):
with ExitStack() as stack:
inputs = [
stack.enter_context(Path(path).open(encoding="utf-8"))
for path in sources
]
output = stack.enter_context(
Path(destination).open("w", encoding="utf-8")
)
for source in inputs:
output.write(source.read())
output.write("\n")If opening the third source fails, the first two are already registered and will be closed. This is safer than collecting file objects in a list and attempting manual cleanup after an exception.
Register cleanup with callback
Some resources do not implement the context manager protocol. The callback() method registers a normal function and its arguments.
from contextlib import ExitStack
from pathlib import Path
with ExitStack() as stack:
temporary = Path("processing.tmp")
temporary.write_text("data", encoding="utf-8")
stack.callback(temporary.unlink, missing_ok=True)
process_data(temporary)
# the temporary file is removedA regular callback does not receive exception details and cannot suppress an exception. It should perform focused, predictable cleanup.
Use push for existing exits
push() accepts a context manager or a function compatible with the __exit__() signature. It is useful when a resource has already been entered and only its exit must be transferred.
from contextlib import ExitStack
resource = create_resource()
resource.__enter__()
with ExitStack() as stack:
stack.push(resource)
use_resource(resource)Prefer enter_context() when possible because it keeps acquisition and registration together. Use push() only for APIs that deliberately separate those phases.
Transfer responsibility with pop_all
Sometimes a function must acquire a complete group of resources and return them still open only when every acquisition succeeds. pop_all() moves all pending exits into a new ExitStack.
from contextlib import ExitStack
def open_all(paths):
stack = ExitStack()
try:
files = [
stack.enter_context(open(path, encoding="utf-8"))
for path in paths
]
except Exception:
stack.close()
raise
owner = stack.pop_all()
return files, owner
files, owner = open_all(["a.txt", "b.txt"])
try:
print([file.readline() for file in files])
finally:
owner.close()After pop_all(), the original stack no longer owns those exits. The returned object must be closed explicitly or used in another with statement.
Partial acquisition safety
A major strength of ExitStack is automatic cleanup after partial success. Consider a process that opens a file, acquires a lock, and begins a database transaction:
from contextlib import ExitStack
with ExitStack() as stack:
source = stack.enter_context(open("data.csv", encoding="utf-8"))
lock = stack.enter_context(acquire_lock("import"))
transaction = stack.enter_context(database.transaction())
import_rows(source, transaction)If the lock cannot be acquired, the file closes. If entering the transaction fails, the lock and file are released. The code does not leave a hidden intermediate state.
Distinguish entry errors from body errors
Sometimes error handling must identify whether a failure happened while entering a context or while using the resource.
from contextlib import ExitStack
stack = ExitStack()
try:
connection = stack.enter_context(connect())
except OSError as error:
handle_connection_failure(error)
else:
with stack:
run_job(connection)This arrangement prevents an OSError raised by run_job() from being mistaken for a connection acquisition error.
Optional context managers
ExitStack is useful when resources are conditional:
from contextlib import ExitStack
with ExitStack() as stack:
source = stack.enter_context(open("data.txt", encoding="utf-8"))
if use_lock:
stack.enter_context(distributed_lock("data"))
if use_transaction:
stack.enter_context(database.transaction())
process(source)This avoids a large tree of branches containing every possible combination of nested with statements.
Compensating actions and rollback
Callbacks can model compensating actions for multi-step operations.
from contextlib import ExitStack
with ExitStack() as stack:
user = create_user()
stack.callback(delete_user, user.id)
profile = create_profile(user.id)
stack.callback(delete_profile, profile.id)
confirm_operation()
stack.pop_all()If any step fails, cleanup runs in reverse order. If every step succeeds, pop_all() removes the rollback plan. This pattern is practical, but a real database transaction is preferable when atomicity is available.
Cleanup failures matter
Exit functions may also fail. Do not silently ignore errors from closing network clients, flushing buffers, committing or rolling back transactions, or removing temporary data. Decide how cleanup failures should be logged and chained with the original exception.
Test cases where the body fails and one or more exit functions also fail. These scenarios reveal whether the application preserves the most useful diagnostic information.
AsyncExitStack
Asynchronous programs can use AsyncExitStack.
from contextlib import AsyncExitStack
async with AsyncExitStack() as stack:
session = await stack.enter_async_context(create_session())
response = await stack.enter_async_context(session.get(url))
data = await response.json()AsyncExitStack supports synchronous and asynchronous cleanup, but the appropriate methods must be used: enter_async_context(), push_async_exit(), and push_async_callback().
Test exit order
from contextlib import contextmanager, ExitStack
events = []
@contextmanager
def resource(name):
events.append(f"enter:{name}")
try:
yield name
finally:
events.append(f"exit:{name}")
with ExitStack() as stack:
stack.enter_context(resource("A"))
stack.enter_context(resource("B"))
assert events == ["enter:A", "enter:B", "exit:B", "exit:A"]Also test a failure during the second entry, an exception in the body, callback arguments, pop_all(), and cleanup failures.
Common mistakes
- Using
push()whenenter_context()is safer. - Creating an ExitStack outside
withand forgetting to close it. - Calling
pop_all()without clearly transferring ownership. - Registering callbacks that capture changing mutable values unexpectedly.
- Assuming a regular callback can suppress exceptions.
- Implementing manual rollback where a database transaction is more reliable.
- Failing to test reverse cleanup order.
Best practices
- Register each resource immediately after acquisition.
- Prefer
with ExitStack()for automatic closure. - Keep callbacks small, deterministic, and idempotent.
- Document ownership after
pop_all(). - Separate acquisition errors from processing errors when needed.
- Use AsyncExitStack in asynchronous workflows.
- Test failure at every acquisition step.
When ExitStack is the right choice
Use ExitStack when the resource count is dynamic, resources are optional, different cleanup mechanisms must be coordinated, or a multi-step acquisition requires predictable rollback. For two fixed and simple resources, a normal with statement is usually clearer.
Conclusion
Python ExitStack turns dynamic resource management into an explicit and reliable cleanup stack. It closes resources in reverse order, combines context managers with callbacks, and supports transferring ownership when necessary.
Register cleanup as soon as a resource is acquired, keep callbacks understandable, and test every error path. See the official ExitStack documentation and the context manager protocol reference for complete behavior details.







