Most introductions to context managers in Python show one example —
with open("file.txt") as f: — and call it a day. That’s enough to use
them, but it doesn’t tell you when to write one.
After a few years of writing Python services, I keep reaching for context
managers in three specific situations. Each one solves a problem that
try/finally can technically solve but tends to get wrong in practice.
Case 1: Pairing acquire and release
The classic case. You have something that must be released — a lock, a database connection, a temporary file, a network socket — and you want to make sure release happens even if the code in between raises.
from contextlib import contextmanager
import threading
_lock = threading.Lock()
@contextmanager
def critical_section():
_lock.acquire()
try:
yield
finally:
_lock.release()
with critical_section():
do_dangerous_thing()
Why not just try/finally? You can — and at the call site, that’s all
the context manager expands to. The win is that the try/finally lives
in the helper, not the call site. Every caller gets it for free, and
nobody can forget to write the finally block.
When I see five copies of try: thing.acquire(); ...; finally: thing.release() in a codebase, I know there’s a context manager waiting
to be extracted.
Case 2: Temporarily changing global-ish state
This one is less talked about, but it’s where context managers really earn their keep. You want to flip some setting for the duration of a block, and you want it back to what it was no matter how the block exits.
import os
from contextlib import contextmanager
@contextmanager
def env(**overrides):
"""Temporarily set environment variables, restoring previous values on exit."""
saved = {k: os.environ.get(k) for k in overrides}
os.environ.update({k: str(v) for k, v in overrides.items()})
try:
yield
finally:
for k, prev in saved.items():
if prev is None:
os.environ.pop(k, None)
else:
os.environ[k] = prev
with env(DEBUG="1", REGION="us-east-1"):
run_test_suite()
# Environment is back to whatever it was here.
The same pattern works for sys.path, logging levels, decimal
contexts, mocked attributes — anything that follows the “save, change,
restore” shape. Tests in particular benefit from this; the alternative
is fixtures that leak when something raises mid-test.
The subtle part is restoring None correctly. A common bug is to do
os.environ[k] = saved[k] without checking — that writes the literal
string "None" into the variable when it didn’t exist before. Always
restore “absent” as pop, not as a string.
Case 3: Suppressing exceptions you genuinely want to ignore
Sometimes you really do want to swallow a specific exception class and
move on. Python ships contextlib.suppress for this:
from contextlib import suppress
with suppress(FileNotFoundError):
os.unlink("maybe-stale.lock")
This is dramatically clearer than the equivalent try/except: pass,
because the limited surface forces you to be specific. You can’t
accidentally suppress everything — you have to name the class. And you
can’t accidentally suppress code below the cleanup; the with block’s
scope is exactly what you wrote.
I find this useful for cleanup in destructors and atexit handlers, where you really cannot afford the cleanup itself to raise.
When not to write one
Context managers are not free. Each with introduces a small amount of
machinery, and stacking them affects readability fast. I avoid them
when:
- The “acquire” half doesn’t actually need a paired “release” — just call the function.
- The cleanup is best-effort and the scope is small enough that
try/finallyreads more clearly inline. - The thing being managed is already managed by something else (e.g.
don’t wrap a
Sessionfrom a framework that already context-manages its own lifecycle).
The test I use: “if I leave the cleanup out, will the next person silently leak resources?” If yes, write the context manager. If no, a plain function is fine.
A note on async
In async code, use @asynccontextmanager and async with. The shape
is identical; the only thing to remember is that you can await inside
the body, which makes the pattern even more useful for things like
“acquire a connection from a pool, run a query, return it.”
from contextlib import asynccontextmanager
@asynccontextmanager
async def borrowed(pool):
conn = await pool.acquire()
try:
yield conn
finally:
await pool.release(conn)
That’s it. Three patterns covers maybe 90% of the context managers I’ve written. The other 10% are weird and you’ll know one when you see it.