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:
- Evaluate
EXPRto get the manager. - Call its
__enter__(), and bind the return value toname. - Run
BODY. - Call
__exit__(exc_type, exc, traceback), with threeNones if the body succeeded or the exception’s details if it raised. - 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)acquire orders
working with orders
release orders, exc_type=ValueError
caught: bad rowThe 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")ZeroDivisionError vanishedThis 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")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")open report.csv
close never printedWrap the yield in try/finally:
@contextmanager
def safe(name):
print("open", name)
try:
yield name
finally:
print("close", name)open report.csv
close report.csv
caught after closeThis 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,
):
...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"]]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.csvThe 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()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.