Context Managers
The with statement, why open() is the gold standard, and how to write your own context managers for transactions, temp dirs, and guaranteed teardown.
What you'll learn
- What the with statement actually does — __enter__ and __exit__
- Why open() is the canonical example, and what leaks without it
- contextlib.contextmanager for writing one quickly with yield
- Real uses — DB transactions, temp dirs, and reverse-order cleanup
Before you start
A context manager is an object that knows how to set up a resource and, crucially, how to tear it down — a file to close, a lock to release, a database transaction to commit or roll back, a temporary directory to delete. The with statement is the syntax that drives it, and its one promise is the valuable one: the teardown runs no matter what, even if the code in the middle raises an exception. That guarantee is why with shows up in almost every serious Python program.
The leak you have probably written
Here is the bug context managers exist to prevent. The cleanup line sits after the work — and never runs if the work raises:
# BAD: the file stays open if anything raises before .close().
def write_bad(path):
f = open(path, "w")
f.write("hello\n")
raise RuntimeError("boom") # f.close() below is never reached
f.close()
try:
write_bad("/tmp/demo.txt")
except RuntimeError as e:
print("got:", e)
print("file leaked — descriptor stays open until the garbage collector runs")
got: boom
file leaked — descriptor stays open until the garbage collector runs
The f.close() after the raise is dead code. On a busy server doing this in a loop, leaked descriptors pile up until the process hits its file-handle limit and everything grinds to a halt. The fix is one keyword.
The with statement, done right
def write_good(path):
with open(path, "w") as f:
f.write("hello\n")
raise RuntimeError("boom")
# __exit__ already ran on the way out — the file is closed despite the raise
try:
write_good("/tmp/demo.txt")
except RuntimeError as e:
print("got:", e)
# The exception still propagated, but the file was closed AND flushed first:
with open("/tmp/demo.txt") as f:
print("contents:", repr(f.read()))
got: boom
contents: 'hello\n'
Same logic, one extra keyword. The exception still propagates — with does not swallow it — but the file is closed and its buffer flushed on the way out regardless, which is why reading it back shows the hello made it to disk.
The protocol — __enter__ and __exit__
A context manager is simply any object with two methods. with X as y: calls X.__enter__() and binds its return value to y; when the block ends — normally or by exception — it calls X.__exit__(). To see the protocol clearly, here is a tracer whose only job is to announce when each method fires:
class Trace:
def __init__(self, label):
self.label = label
def __enter__(self):
print(f" enter {self.label}")
return self # this is what 'as t' receives
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is None:
print(f" exit {self.label} (clean)")
else:
print(f" exit {self.label} (saw {exc_type.__name__})")
return False # falsy -> let any exception propagate
with Trace("block A") as t:
print(" inside, t.label =", t.label)
print()
try:
with Trace("block B"):
raise ValueError("boom")
except ValueError as e:
print("caught:", e)
enter block A
inside, t.label = block A
exit block A (clean)
enter block B
exit block B (saw ValueError)
caught: boom
Trace the two blocks. The first exits cleanly, so __exit__ sees exc_type is None. The second raises inside the block — and __exit__ still runs, now told exactly which exception occurred through its three arguments (exc_type, exc_value, traceback). Returning a falsy value (False or None) means “I cleaned up, now let the exception continue”, which is the normal case; returning True would suppress the exception, which you want only rarely.
The fast path — @contextmanager
Writing a whole class for every context manager is more ceremony than most cases deserve. contextlib.contextmanager turns a generator into one: everything before the yield is setup, everything after is teardown, and the with-block runs at the yield:
from contextlib import contextmanager
@contextmanager
def trace(label):
print(f" enter {label}")
try:
yield # the body of the with-block runs here
finally:
print(f" exit {label}")
with trace("download"):
print(" ... working ...")
enter download
... working ...
exit download
The try/finally is doing essential work. Because the teardown lives in the finally, it runs even if the with-block raises — without it, an exception in the body would skip straight past the cleanup, defeating the entire purpose.
A real example — a database transaction
Commit on success, roll back on failure: that discipline is easy to forget by hand and impossible to forget inside a context manager. Here is the whole pattern with a fake database so the output is exact:
from contextlib import contextmanager
class FakeDB:
def __init__(self):
self.committed = []
self.pending = []
def insert(self, row):
self.pending.append(row)
print(f" staged: {row}")
def commit(self):
self.committed.extend(self.pending)
self.pending.clear()
print(" COMMIT")
def rollback(self):
self.pending.clear()
print(" ROLLBACK")
@contextmanager
def transaction(db):
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
db = FakeDB()
# Happy path — commits.
with transaction(db) as tx:
tx.insert({"id": 1, "user": "Aarav"})
tx.insert({"id": 2, "user": "Priya"})
# Failure path — rolls back, and the exception still propagates.
try:
with transaction(db) as tx:
tx.insert({"id": 3, "user": "Diego"})
raise ValueError("payment failed")
except ValueError as e:
print("caught:", e)
print("committed rows:", db.committed)
staged: {'id': 1, 'user': 'Aarav'}
staged: {'id': 2, 'user': 'Priya'}
COMMIT
staged: {'id': 3, 'user': 'Diego'}
ROLLBACK
caught: payment failed
committed rows: [{'id': 1, 'user': 'Aarav'}, {'id': 2, 'user': 'Priya'}]
Two paths, one shape. The first block committed both rows; the second staged a row, hit a ValueError, rolled the staging back, and re-raised — so Diego never reached committed. The with block makes that commit-or-rollback discipline structural rather than something you have to remember.
Multiple contexts at once
You can manage several resources in one with. Since Python 3.10, parentheses let you split them across lines — much tidier for three or more:
# One line — fine for two, gets unwieldy fast.
with open("in.csv") as src, open("out.csv", "w") as dst:
dst.write(src.read())
# Python 3.10+ — parentheses allow a clean line break per resource.
with (
open("in.csv") as src,
open("out.csv", "w") as dst,
):
dst.write(src.read())
Contexts unwind in reverse order — the last one entered is the first one exited. That is almost always what you want: close the output before the input, release the inner lock before the outer.
A throwaway — a temporary directory
tempfile.TemporaryDirectory makes a real directory and deletes it (and everything inside) on exit, even if the block raises:
import tempfile, os
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "scratch.txt")
with open(path, "w") as f:
f.write("temporary stuff")
print("exists during with:", os.path.exists(path))
print("exists after with: ", os.path.exists(path))
exists during with: True
exists after with: False
In one breath
with X as y:callsX.__enter__()(bindingy) and is guaranteed to callX.__exit__()on the way out.__exit__runs on success and on exception; it receives the exception details and returns falsy to let it propagate.@contextmanagerturns a generator into a context manager — setup beforeyield, teardown after, inside atry/finally.- Use it for any acquire/release resource — files, locks, transactions, temp dirs.
- Multiple contexts unwind in reverse order; the 3.10+ parenthesised form keeps them readable.
Practice
Quick check
What’s next
Exceptions have surfaced in nearly every lesson. It is time to meet them properly — errors and exceptions: the hierarchy, how to catch them well, and how to raise your own.
Practice this in an interview
All questionsA context manager is any object that implements __enter__ and __exit__. The with statement calls __enter__ on entry and __exit__ on exit — guaranteed, even if an exception is raised. This makes with the idiomatic way to manage resources like files, locks, and database transactions without leaking them.
A closure is a function that retains access to variables from its enclosing scope even after that scope has finished executing. It is the mechanism behind decorators, factory functions, and stateful callbacks without needing a class.
CPU-bound work keeps the processor busy the whole time — matrix multiplication, compression, parsing. I/O-bound work spends most of its time waiting for a slow external resource — network, disk, database. The distinction directly determines which concurrency primitive to reach for: multiprocessing for CPU-bound (bypasses the GIL), threading or asyncio for I/O-bound (GIL released during waits).
Use threading for I/O-bound work — network calls, file reads, database queries — because threads release the GIL during blocking syscalls and share memory cheaply. Use multiprocessing for CPU-bound work — number crunching, image processing — because each process gets its own GIL and can run on a separate core.