Ch. 9 · Python

Python Context Managers and the with Statement Explained

What the with statement guarantees, how __exit__ can swallow exceptions, the missing try/finally in @contextmanager, and ExitStack.

~6 min readintermediateupdated Oct 7, 2026

A context manager pairs a setup step with a cleanup step and makes the cleanup impossible to forget. with open(path) as f: closes the file whether the block finishes normally, raises, or returns early. Locks, database transactions, temporary directories and patched settings in tests all follow the same shape. The interview questions here are less about syntax and more about ownership: who releases the resource, when, and what happens to an exception on the way out.

What with actually runs

The statement with EXPR as name: BODY does roughly this:

  1. Evaluate EXPR to get the manager.
  2. Call its __enter__(), and bind the return value to name.
  3. Run BODY.
  4. Call __exit__(exc_type, exc, traceback), with three Nones if the body succeeded or the exception’s details if it raised.
  5. If __exit__ returns a truthy value, swallow the exception. Otherwise let it propagate.

A small class makes the order visible:

class Lock:
    def __init__(self, name):
        self.name = name
    def __enter__(self):
        print(f"acquire {self.name}")
        return self
    def __exit__(self, exc_type, exc, tb):
        print(f"release {self.name}, exc_type={exc_type.__name__ if exc_type else None}")
        return False

try:
    with Lock("orders") as lock:
        print("working with", lock.name)
        raise ValueError("bad row")
except ValueError as e:
    print("caught:", e)
python
acquire orders
working with orders
release orders, exc_type=ValueError
caught: bad row
Text

The release happened before the caller’s except saw the error. That ordering is the whole point: by the time anyone handles the failure, the lock is already free.

Two details are easy to get wrong. First, the value after as is whatever __enter__ returns, not the manager itself. For files they’re the same object, but with lock: on a threading.Lock binds True, and some database libraries return a cursor or connection. Second, if __enter__ raises, __exit__ is never called. The resource wasn’t acquired, so there’s nothing to release. Cleanup code that assumes otherwise can double-release.

The exception-swallowing bug

Because a truthy return value suppresses the exception, a careless __exit__ can hide failures:

class Swallow:
    def __enter__(self): return self
    def __exit__(self, *exc):
        return True

with Swallow():
    1 / 0
print("ZeroDivisionError vanished")
python
ZeroDivisionError vanished
Text

This usually happens by accident: someone writes return self.cleanup() and cleanup happens to return a truthy value, or they return True thinking it signals “cleanup succeeded.” Make __exit__ return False or None unless suppression is the explicit feature. When it is the feature, use the standard library’s contextlib.suppress(FileNotFoundError), which names the exact exceptions it swallows and documents intent at the call site:

from contextlib import suppress
import os

with suppress(FileNotFoundError):
    os.remove("does-not-exist.tmp")
python

Writing one with @contextmanager

For most custom managers, contextlib.contextmanager is shorter than a class. Code before yield is the setup, the yielded value is what as binds, and code after yield is the cleanup. The trap is that an exception in the with body is re-raised at the yield, so anything after it is skipped unless you protect it:

from contextlib import contextmanager

@contextmanager
def leaky(name):
    print("open", name)
    yield name
    print("close", name)

try:
    with leaky("report.csv"):
        raise RuntimeError("boom")
except RuntimeError:
    print("close never printed")
python
open report.csv
close never printed
Text

Wrap the yield in try/finally:

@contextmanager
def safe(name):
    print("open", name)
    try:
        yield name
    finally:
        print("close", name)
python
open report.csv
close report.csv
caught after close
Text

This is the single most common mistake with generator-based managers, and reviewers look for it. If you want to react to the error (roll back a transaction, say), use except before the finally and re-raise. Also make sure the generator yields exactly once; yielding twice raises RuntimeError: generator didn't stop.

Many resources: nesting and ExitStack

For a fixed number of resources, a single with can hold several managers, and Python 3.10 added parenthesised groups so they can span lines:

with (
    open("in.csv") as src,
    open("out.csv", "w") as dst,
):
    ...
python

They’re entered left to right and exited right to left, and if the second open fails, the first file is still closed.

When the number isn’t known until runtime (merging every CSV in a folder, say), use contextlib.ExitStack. It registers each resource as you enter it and unwinds them all in reverse order on the way out, including when a later one fails to open:

from contextlib import ExitStack

with ExitStack() as stack:
    files = [stack.enter_context(Res(n, fail=(n == "c.csv")))
             for n in ["a.csv", "b.csv", "c.csv"]]
python

With a small Res class that prints on open and close and raises OSError for c.csv, the output is:

open a.csv
open b.csv
close b.csv
close a.csv
error: cannot open c.csv
Text

The two files that did open were closed, newest first, before the error reached the caller. Writing that by hand with nested try/finally blocks for a variable count is fiddly and easy to get wrong. ExitStack also has callback(fn, *args) for cleanup that isn’t a context manager, and pop_all() for the pattern “acquire everything, and only if all succeed, hand ownership to the caller.”

Ownership: who closes it?

The deeper question behind context managers is which code owns a resource. A useful rule: whoever opens it closes it. A function that takes an open file object as a parameter shouldn’t close it, because the caller may still need it. A function that takes a path and opens the file itself should close it before returning, which with makes automatic.

Things get muddled when a function returns a resource. def get_conn(): return psycopg.connect(...) hands ownership to the caller, and nothing enforces that the caller closes it. Returning a context manager instead (with get_conn() as conn:) makes the lifetime visible where it’s used. The same thinking applies to generators that hold resources: a generator that opens a file and yields lines only closes it when it’s exhausted or explicitly closed, so a caller that stops after the first match leaves the file open until garbage collection.

Async code has the same protocol under different names. async with calls __aenter__ and __aexit__, which are coroutines, so setup and cleanup can await network calls: an HTTP client session, a database pool, a lock from asyncio. contextlib.asynccontextmanager and AsyncExitStack mirror their synchronous versions, including the need for try/finally around the yield. Mixing them up fails loudly: using a plain with on an object that only defines the async methods raises a TypeError, and Python 3.14’s message even suggests the fix: 'A' object does not support the context manager protocol (missed __exit__ method) but it supports the asynchronous context manager protocol. Did you mean to use 'async with'?

Interview practice: a transaction helper

Write a context manager transaction(conn) that commits if the block succeeds and rolls back if it raises. What should happen to the exception?

Answer

from contextlib import contextmanager

@contextmanager
def transaction(conn):
    try:
        yield conn
    except BaseException:
        conn.rollback()
        raise
    else:
        conn.commit()
python

The exception must propagate after the rollback: the caller needs to know the work didn’t happen, and swallowing it would turn a failed write into a silent success. Catching BaseException rather than Exception means a KeyboardInterrupt or task cancellation mid-transaction also rolls back, and the bare raise re-raises the original exception with its traceback intact. The commit sits in else so it runs only when the body didn’t raise. A good follow-up point: if commit() itself fails, that exception propagates too, which is correct. Many database drivers already provide this behaviour through with conn:, so in real code you’d check the driver’s documentation before writing your own.

Where to go next

More in Python

read ✓Python · hard

Python asyncio.gather and Timeouts

What asyncio.gather does when one task fails or a deadline passes, and when to use return_exceptions, asyncio.timeout, wait or TaskGroup.

~7 min readread →
esc