Why juggling resources gets messy

Opening a database connection, a file handle, and a network socket in the same function often leads to a nest of try/finally blocks. Forgetting to close one of them leaks resources and can cause subtle bugs that surface only under load.

Python’s contextlib.ExitStack solves this by letting you register an arbitrary number of cleanup callbacks and guaranteeing they run in reverse order, even if an exception occurs halfway through.

A real‑world scenario

Imagine a data‑pipeline step that reads a large CSV, writes a temporary Parquet file, and then uploads the result to an object store. Each of those operations owns a resource that must be released: the CSV file handle, the temporary file descriptor, and the HTTP connection. Using ExitStack keeps the code linear and readable.

Clean, production‑ready example

import contextlib
import csv
import tempfile
import requests
from pathlib import Path

def process_and_upload(csv_path: Path, upload_url: str) -> None:
    """Read CSV, convert to Parquet, upload, and clean up everything."""
    with contextlib.ExitStack() as stack:
        # 1. Open the source CSV
        csv_file = stack.enter_context(open(csv_path, newline='', encoding='utf-8'))
        reader = csv.DictReader(csv_file)

        # 2. Create a temporary file for Parquet output
        tmp = stack.enter_context(tempfile.NamedTemporaryFile(suffix='.parquet', delete=False))
        tmp_path = Path(tmp.name)
        # In a real project you'd use pyarrow or fastparquet here.
        # For brevity we just write a placeholder.
        tmp.write(b'PARQUET_PLACEHOLDER')
        tmp.flush()

        # 3. Prepare the HTTP session – it also implements __enter__/__exit__
        session = stack.enter_context(requests.Session())

        # 4. Upload the temporary file
        with tmp_path.open('rb') as data:
            response = session.post(upload_url, files={'file': data})
            response.raise_for_status()

        # No explicit cleanup needed – ExitStack closes csv_file,
        # deletes the temp file (because delete=False we remove manually),
        # and closes the session automatically.
        tmp_path.unlink(missing_ok=True)

# Usage
if __name__ == '__main__':
    process_and_upload(Path('input.csv'), 'https://example.com/upload')

What makes this work

  • enter_context registers any object that implements the context‑manager protocol. It returns the object itself, so you can keep using it normally.
  • The stack guarantees LIFO cleanup: the last entered context exits first. That matches the natural dependency order (close HTTP session before deleting the temp file).
  • If an exception bubbles up, ExitStack still runs all registered callbacks, preventing leaks.

Tip: When you need conditional resources — e.g., only open a cache connection if a flag is set — push the context manager onto the stack only when the condition is true. The stack stays clean either way.

Why not just nest with statements?

Nested with blocks work for a fixed, small number of resources. As soon as the count becomes dynamic (config‑driven, plugin‑based, or optional), the code balloons into a pyramid of indentation. ExitStack flattens that structure, making the flow easier to read and the cleanup logic centralized.

Edge cases to watch

  1. Suppressing exceptions: ExitStack can swallow an exception if a callback returns True. Use stack.push(callback) only when you intentionally want that behavior.
  2. Non‑context‑manager cleanup: For plain functions (e.g., os.remove) wrap them with contextlib.closing or stack.callback(fn, *args).
  3. Thread safety: Each thread gets its own stack instance; sharing a single ExitStack across threads is not safe.

Putting it in the toolbox

I keep a small utility module that exports a helper managed_resources(*managers) returning an ExitStack pre‑loaded with the given managers. It lets me write:

with managed_resources(open('a.txt'), open('b.txt'), requests.Session()) as (f1, f2, sess):
    ...

The helper is just a thin wrapper around ExitStack.enter_context in a loop, but it saves a line of boilerplate every time.

Final thoughts

Resource management is one of those cross‑cutting concerns that, when handled consistently, eliminates a whole class of production incidents. contextlib.ExitStack gives you a single, well‑tested abstraction for that job. Next time you find yourself writing multiple try/finally blocks, consider reaching for the stack instead.