Overview
Everyone learns with open(...) as f on day one and then stops thinking about it. The with statement is a general mechanism for "do setup, do work, do teardown, even if the work explodes." Once you start writing your own, you find uses for it constantly — database transactions, temporary state changes, timing blocks, and locking.
Here's the actual mechanism, and then the patterns I use.
What with really does
When Python executes:
with expr as target:
body
It does roughly this:
manager = expr
value = manager.__enter__()
target = value
try:
body
except:
if not manager.__exit__(*sys.exc_info()):
raise
else:
manager.__exit__(None, None, None)
Two dunder methods. __enter__ runs before the block, __exit__ runs after — always, whether the block raised or not. The return value of __exit__ decides whether an exception propagates: return True to swallow it, anything falsy to let it through.
That last part is the feature almost nobody uses. A context manager can absorb specific exceptions, which is how some libraries implement "retry on failure" or "log and continue."
Class-based: the explicit version
class Timer:
def __init__(self, label=""):
self.label = label
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.elapsed = time.perf_counter() - self.start
print(f"{self.label}: {self.elapsed:.3f}s")
return False # do not suppress exceptions
with Timer("db query"):
results = run_expensive_query()
__exit__ returning False (or nothing) means exceptions propagate normally. That's almost always what you want. Returning True makes the exception disappear, which is a great way to introduce silent failures into a codebase.
contextlib.contextmanager: the shorter version
For simple cases, you don't need a class. Decorate a generator and everything before yield is the setup, everything after is the teardown:
from contextlib import contextmanager
@contextmanager
def timer(label=""):
start = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start
print(f"{label}: {elapsed:.3f}s")
Ten lines instead of fifteen, and easier to read.
The try/finally is not optional. If you skip it and the block raises, the teardown never runs. This is the most common bug in hand-rolled context managers.
The generator form can also return a value:
@contextmanager
def open_config(path):
with open(path) as f:
cfg = json.load(f)
yield cfg
with open_config("settings.json") as cfg:
print(cfg["debug"])
Real use: temporary directory change
@contextmanager
def chdir(path):
prev = os.getcwd()
os.chdir(path)
try:
yield
finally:
os.chdir(prev)
with chdir("/tmp/build"):
subprocess.run(["make"])
This one saves real code. Any time you need to change global state temporarily — working directory, environment variables, locale, logging level — a context manager guarantees the restoration even if the code inside throws. Without it, you eventually ship a bug where the process runs the rest of its life in the wrong directory.
Real use: environment variables
@contextmanager
def env(**overrides):
original = {k: os.environ.get(k) for k in overrides}
os.environ.update(overrides)
try:
yield
finally:
for k, v in original.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
with env(DATABASE_URL="sqlite://:memory:", DEBUG="1"):
run_tests()
I use this in test suites constantly. Tests that mutate os.environ directly are a nightmare to debug when they leak into each other.
Suppressing exceptions
The contextlib.suppress helper is built in and handles the "I expect this to fail and don't care" case:
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove("tempfile.txt")
with suppress(KeyError, IndexError):
value = data["config"]["missing"]
Use this sparingly. Catching an exception you didn't actually anticipate hides real bugs. But for genuinely expected failures — a temp file that might not exist — it's cleaner than a two-line try/except.
Reusable context managers
Two things that aren't obvious:
A class-based context manager can be reused. You can call it multiple times on the same instance because __enter__ resets state:
t = Timer("phase")
with t:
phase_one()
with t:
phase_two()
A generator-based one cannot. Once the generator is exhausted, calling it again raises RuntimeError: generator didn't yield. Each use needs a fresh call. This bites people who define one with @contextmanager and then try to reuse the variable.
# Broken
cm = timer("x")
with cm: pass
with cm: pass # RuntimeError
# Fine
with timer("x"): pass
with timer("x"): pass
Nested and multiple contexts
Multiple context managers on one line run in order, and exit in reverse:
with open("input.txt") as src, open("output.txt", "w") as dst:
dst.write(src.read())
Both __exit__ methods run, in reverse order, even if the body raises. That's why this is safe.
Async context managers
Async versions use __aenter__ and __aexit__, and the keyword is async with:
@asynccontextmanager
async def session_scope():
session = AsyncSession()
try:
yield session
await session.commit()
except:
await session.rollback()
raise
finally:
await session.close()
async with session_scope() as s:
s.add(User(name="alice"))
This is the standard pattern for async database sessions. The commit-on-success / rollback-on-failure behavior is exactly what __aexit__ is designed to express, and doing it manually in every function is worse in every way.
ExitStack: dynamic context management
Sometimes you don't know how many context managers you need at compile time. ExitStack handles it:
from contextlib import ExitStack
with ExitStack() as stack:
files = [stack.enter_context(open(p)) for p in paths]
# all files close when the block exits, even if this list is empty
for f in files:
process(f)
Less common, but when you need it, you really need it. The alternative is a hand-managed list of open files and a try/finally, which is exactly what ExitStack is abstracting.
When not to use one
Context managers are for setup and teardown that must happen as a pair. If you just need cleanup at the end of a function, try/finally is simpler. If you need to conditionally clean up based on success, a plain function with an explicit close() is often clearer than a context manager — the with syntax hides the operation, which is a bug when the operation matters.
But for anything resource-like — files, connections, locks, temp state — the with statement is the right tool, and writing your own is a five-minute investment that pays off for the rest of the codebase's life.
