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:

  1. Call cm.__enter__() and store the result.
  2. 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 ExitStack with bare try/finally for 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_context returns 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.