Managing Multiple Context Managers in Python with contextlib.ExitStack
Why managing multiple resources is painful
Opening a handful of files, acquiring a lock, and starting a database transaction in the same function often leads to a pyramid of with statements. If one of them fails, you have to remember which ones were already entered so you can clean them up manually. That boiler‑plate is error‑prone and makes the code hard to read.
When the number of resources isn’t known at write time — think of a batch job that opens *N* log files based on a config file — static nesting simply doesn’t scale.
Enter ExitStack
contextlib.ExitStack gives you a single context manager that can register an arbitrary number of other context managers at runtime. It guarantees that all entered resources are exited in reverse order, even if an exception occurs halfway through.
from contextlib import ExitStack
def process_batch(paths):
"""Open every file in *paths* and return a list of file objects."""
with ExitStack() as stack:
files = [stack.enter_context(open(p, 'r')) for p in paths]
# All files are now open; work with *files* here.
data = [f.read() for f in files]
# ExitStack closed every file automatically.
return data
The enter_context method registers the supplied context manager and returns the value its __enter__ method yields. When the with ExitStack() block ends, ExitStack.__exit__ calls each registered manager’s __exit__ in LIFO order.
Real‑world example: batch processing files
Imagine a nightly ETL job that reads a variable number of CSV files, validates each row, and writes a summary to a single output file. The list of input files comes from a directory scan, so you can’t hard‑code the with nesting.
import csv
from pathlib import Path
from contextlib import ExitStack
def run_etl(input_dir: Path, output_path: Path) -> None:
csv_files = sorted(input_dir.glob('*.csv'))
with ExitStack() as stack:
# Open all input files
readers = [
stack.enter_context(open(f, newline='', encoding='utf-8'))
for f in csv_files
]
# Wrap each file handle with csv.reader
csv_readers = [csv.reader(r) for r in readers]
# Open the single output file
out_file = stack.enter_context(open(output_path, 'w', newline='', encoding='utf-8'))
writer = csv.writer(out_file)
writer.writerow(['source_file', 'row_count'])
for path, reader in zip(csv_files, csv_readers):
row_count = sum(1 for _ in reader)
writer.writerow([path.name, row_count])
# All files — inputs and output — are closed here, even on error.
Notice how the output file is registered *after* the inputs. Because ExitStack unwinds in reverse order, the output file closes first, guaranteeing that any buffered data is flushed before the input handles are released.
How it works under the hood
ExitStack maintains a list of _exit_callbacks. Each call to enter_context(cm) does roughly:
- Call
cm.__enter__()and store the result. - Push a wrapper that invokes
cm.__exit__(*exc_details)onto the callback stack.
When the stack exits, it iterates the callbacks in reverse, passing any exception information. This design mirrors the language’s own with semantics but lets you decide *when* and *how many* managers to register.
Gotchas and best practices
- Don’t mix
ExitStackwith baretry/finallyfor the same resources. Doing so can double‑close a handle or swallow exceptions. - Use
stack.callback(func, *args)for non‑context‑manager cleanup. It registers a plain callable that runs on exit, useful for releasing a custom lock or deleting a temporary file. - Keep the
with ExitStack()block as small as possible. Register everything you need, do the work, then let the block end. Long‑lived stacks make debugging harder because the cleanup order is less obvious. - Remember that
enter_contextreturns the context manager’s__enter__value. If you need the raw manager (e.g., to call a method later), store it separately before registering.
Wrapping up
ExitStack turns a fragile tower of nested with statements into a single, readable block that scales with your runtime data. It’s part of the standard library, has zero dependencies, and behaves exactly like the language’s own context‑management protocol. Next time you face a variable‑size resource set — files, sockets, DB connections — reach for ExitStack and let Python handle the cleanup discipline for you.