Why ExitStack?

Most Python developers reach for the with statement when they need deterministic cleanup — files, sockets, database connections, you name it. But what happens when the number of resources isn’t known until runtime? You could nest with blocks manually, but that quickly becomes fragile and hard to read. contextlib.ExitStack was added precisely for this situation: it lets you register an arbitrary number of context managers and guarantees that all of them are exited in the reverse order of registration, even if an exception occurs halfway through.

A Real‑World Scenario

Imagine a data‑pipeline that ingests a variable list of CSV files, opens a connection to a PostgreSQL database for each file, and writes the parsed rows into a staging table. The list of files comes from an upstream service, so you can’t hard‑code the with blocks. Using ExitStack you can open each file and each DB connection dynamically, do the work, and walk away knowing everything will be closed correctly.

Basic Usage

from contextlib import ExitStack

def process_one(file_path, db_dsn):
    with ExitStack() as stack:
        # Register the file handle
        f = stack.enter_context(open(file_path, "r", encoding="utf-8"))
        # Register the DB connection (assuming a context‑manager wrapper)
        conn = stack.enter_context(psycopg2.connect(db_dsn))
        cur = conn.cursor()
        for line in f:
            # simple parsing, real code would use csv module
            cur.execute("INSERT INTO staging (raw) VALUES (%s)", (line.strip(),))
        conn.commit()
        # No explicit close calls needed – ExitStack handles them

The enter_context method returns the object yielded by the context manager’s __enter__, so you can keep using f and conn exactly as you would in a normal with block.

Dynamic Resource Lists

When the resources themselves are produced by a loop, you can push each one onto the stack:

def ingest_files(file_paths, db_dsn):
    with ExitStack() as stack:
        connections = []
        for path in file_paths:
            fh = stack.enter_context(open(path, "r", encoding="utf-8"))
            conn = stack.enter_context(psycopg2.connect(db_dsn))
            connections.append((fh, conn))
        
        for fh, conn in connections:
            cur = conn.cursor()
            for line in fh:
                cur.execute("INSERT INTO staging (raw) VALUES (%s)", (line.strip(),))
            conn.commit()

Notice that we never call close() on the file handles or connections. If an exception bubbles out of the inner loop, ExitStack still unwinds every registered manager, preventing leaked file descriptors or idle DB sessions.

Error Handling and Cleanup Guarantees

Key point: ExitStack mimics the semantics of nested with statements. The last‑registered context manager exits first, which matches the LIFO order you’d get from manual nesting.

If you need to run custom cleanup code that isn’t wrapped in a context manager, use stack.callback:

with ExitStack() as stack:
    fh = stack.enter_context(open("data.txt"))
    # Register a lambda that runs after all context managers exit
    stack.callback(lambda: logger.info("Finished processing data.txt"))
    # ... do work ...

This is handy for logging, metrics, or releasing non‑context‑manager resources like a thread‑pool handle.

Performance Considerations

ExitStack adds a tiny overhead — essentially a list of callbacks — which is negligible compared to I/O latency. However, avoid registering thousands of trivial context managers in a tight loop; the callback list grows linearly and can affect memory if you keep the stack alive for a long time. In practice, batch the work or reuse a single connection pool instead of opening a new DB connection per file.

Putting It All Together

Here’s a compact, production‑ready helper that encapsulates the pattern:

from contextlib import ExitStack
import psycopg2
import csv

def bulk_load_csv(file_paths, db_dsn, table="staging"):
    """Load each CSV file in  into  using a single ExitStack."""
    with ExitStack() as stack:
        # Open all files first – fails fast if any path is bad
        readers = []
        for path in file_paths:
            fh = stack.enter_context(open(path, newline="", encoding="utf-8"))
            readers.append(csv.reader(fh))
        
        # One shared DB connection (or a pool) – also managed by the stack
        conn = stack.enter_context(psycopg2.connect(db_dsn))
        cur = conn.cursor()
        
        for reader in readers:
            for row in reader:
                placeholders = ",".join(["%s"] * len(row))
                cur.execute(f"INSERT INTO {table} VALUES ({placeholders})", row)
        conn.commit()
        # Automatic cleanup of files and DB connection

With this helper you get deterministic cleanup, readable code, and the flexibility to handle any number of input files without a single explicit close() call. Next time you face a variable‑size resource set, reach for ExitStack — it’s the Swiss‑army knife for context‑manager composition.