Python Context Managers: The Three Cases You Actually Need
Python context managers: the three cases you actually need. Master with statements, contextlib and custom context managers for better resource management.
{</* resource-info */>}
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.
Related Articles #
- Scrapling Reviewed: A Faster, Stealthier Take on Python Scraping — Advanced Python scraping
- Reading EXPLAIN ANALYZE in Postgres Without Getting Lost — Database performance optimization
- Free Claude Code: Use Claude Code CLI for Free with Any AI Provider — AI-assisted coding