How does a context manager work, and how does the `with` statement use `__enter__`/`__exit__`?
Quick Answer
A context manager implements `__enter__` (run when entering the `with` block, its return value bound to the `as` variable) and `__exit__` (run when leaving the block, **even if an exception occurred**, receiving the exception info and able to suppress it by returning `True`). This guarantees cleanup (closing a file, releasing a lock, committing/rolling back a transaction) happens regardless of how the block exits.
Detailed Answer
What with desugars to
with open("file.txt") as f:
data = f.read()
# roughly equivalent to:
f = open("file.txt").__enter__()
try:
data = f.read()
finally:
open("file.txt").__exit__(None, None, None) # (simplified)
__enter__() runs first, and its return value is bound to f (the as
target). __exit__() is guaranteed to run when the block ends,
whether it ends normally or via an exception — this is the core guarantee
with provides over a manual try/finally written by hand every time.
Writing your own context manager (class-based)
class Timer:
def __enter__(self):
import time
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_value, traceback):
import time
self.elapsed = time.perf_counter() - self.start
print(f"Elapsed: {self.elapsed:.3f}s")
return False # False (or None) -- don't suppress any exception
with Timer() as t:
do_expensive_work()
# prints elapsed time whether do_expensive_work() succeeded or raised
__exit__ receives (exc_type, exc_value, traceback) — all None if the
block exited normally, or the actual exception info if one was raised.
Returning True from __exit__ suppresses the exception (swallows
it, as if it never happened); returning False/None lets it propagate
normally after cleanup runs.
A common real-world pattern: guaranteed release
class DatabaseTransaction:
def __enter__(self):
self.conn.begin()
return self.conn
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is None:
self.conn.commit()
else:
self.conn.rollback() # roll back on ANY exception
return False # still propagate the exception after rollback
This is the canonical use of __exit__'s exception parameters: commit on
success, roll back on failure, and let the caller still see the original
exception (since returning False doesn't hide it) — the transaction is
never left half-applied regardless of how the block exits.
Multiple context managers in one with
with open("in.txt") as fin, open("out.txt", "w") as fout:
fout.write(fin.read())
Equivalent to nested with blocks — each context manager's __exit__ is
guaranteed to run (in reverse order of entry) even if a later one's
__enter__ or the block body fails.
Interview-ready summary: with guarantees __exit__ runs on the way
out of the block, exception or not — __enter__ sets up a resource and
__exit__ tears it down, optionally inspecting/suppressing an exception.
This is Python's structured alternative to manually writing
try/finally around every resource-acquiring operation.