What you'll learn
Quick Answer
with calls __enter__ before the block and __exit__ after it, guaranteed — including when an exception is raised. That guarantee is why it is the correct way to handle files, locks and connections.
The problem it solves
Opening a file means you must close it. The naive version does not:
f = open("data.txt")
data = f.read() # if this raises, close() never runs
f.close()
Any exception between the two lines leaks the file handle. The correct manual version needs try/finally:
f = open("data.txt")
try:
data = f.read()
finally:
f.close()
That is verbose enough that people skip it, which is precisely why with exists:
with open("data.txt") as f:
data = f.read()
# closed here, whatever happened
Leaked handles are not theoretical. A long-running process that leaks database connections exhausts the pool and stops serving requests — a real and frequently-seen outage.
The protocol is two methods
class Timer:
def __enter__(self):
print("enter")
return self # bound to the 'as' name
def __exit__(self, exc_type, exc, tb):
print("exit runs even on error:",
exc_type.__name__ if exc_type else "no error")
return False # do not suppress exceptions
with Timer():
print("inside")
# enter / inside / exit runs even on error: no error
Now with an exception:
try:
with Timer():
raise ValueError("boom")
except ValueError:
print("exception propagated out")
# enter
# exit runs even on error: ValueError
# exception propagated out
__exit__ ran, received the exception details, and the exception still propagated. That is the guarantee — cleanup happens, and the error is not hidden.
The return value of __exit__ matters
Returning False (or None, which is the default) lets the exception propagate. Returning True swallows it, and the code after the with block continues as if nothing went wrong.
That is occasionally what you want — contextlib.suppress(FileNotFoundError) is built on exactly this. But accidentally returning a truthy value from __exit__ creates a context manager that silently hides every error inside it, which is an unpleasant bug to track down.
Be explicit and return False unless you genuinely intend suppression.
The easier way: @contextmanager
Writing a class for something simple is heavy. contextlib lets a generator do it:
from contextlib import contextmanager
@contextmanager
def tag(name):
print(f"<{name}>")
yield
print(f"</{name}>")
with tag("b"):
print("bold")
# <b> / bold / </b>
Everything before yield is the setup, everything after is the cleanup, and whatever you yield is bound by as.
One caveat: if the block raises, the exception is thrown at the yield, so code after it does not run unless you wrap it:
@contextmanager
def safe_tag(name):
print(f"<{name}>")
try:
yield
finally:
print(f"</{name}>")
Use try/finally around the yield whenever the cleanup genuinely must happen — which is usually the entire point.
Where to use your own
Anything with a matched pair of setup and teardown:
- Timing a block — record the start in
__enter__, print the elapsed time in__exit__. - Temporarily changing state — switch a setting, restore it afterwards even on failure.
- Database transactions — commit on success, roll back on exception, using the
exc_typeargument to decide. - Acquiring and releasing a lock — Python's
threading.Lockis already a context manager for this reason. - Temporary directories —
tempfile.TemporaryDirectory()creates and removes one.
Several can be combined in one statement, and they nest in order:
with open("in.txt") as src, open("out.txt", "w") as dst:
dst.write(src.read())
The signal that you want one: whenever you write try/finally and the same pairing appears more than once, that is a context manager asking to exist. See Python exception handling for the surrounding machinery.
