What you'll learn
You've used with open(...) to guarantee files close. That's a context manager — a protocol for setup-and-cleanup that always runs, even on error. Now you'll write your own, for timing, transactions, temporary state, or any resource that must be released.
By the end you'll be able to:
- Explain what the
withstatement guarantees - Write a class-based context manager (
__enter__/__exit__) - Write one concisely with
@contextmanager - Use multiple managers together
The with statement, revisited
A context manager wraps a block with guaranteed enter and exit steps. The exit runs no matter how the block ends — normal completion, a return, or an exception. That's why with open(...) can promise the file is always closed. Anything that must be cleaned up — files, locks, database connections, network sockets — is a candidate.
Class-based managers
Implement two dunder methods: __enter__ (setup; its return value is bound to the as variable) and __exit__ (teardown; it receives any exception info). Here's a timer that reports how long its block took:
class Timer:
def __enter__(self):
import time
self.start = time.perf_counter()
return self # value bound to 'as'
def __exit__(self, exc_type, exc_val, tb):
import time
self.elapsed = time.perf_counter() - self.start
print(f"Took {self.elapsed:.4f}s")
# return False -> don't suppress exceptions
with Timer() as t:
total = sum(range(1_000_000))
print("Sum:", total)Note
__exit__ receives the exception type, value, and traceback (or three Nones if the block succeeded). Return True from it to suppress the exception; return False (or nothing) to let it propagate normally — usually what you want.@contextmanager
Writing a class is often overkill. The contextlib.contextmanager decorator turns a generator into a context manager: everything before yield is setup, everything after is teardown, and the yielded value goes to as:
from contextlib import contextmanager
@contextmanager
def tag(name):
print(f"<{name}>") # setup (before yield)
yield # the 'with' body runs here
print(f"</{name}>") # teardown (after yield)
with tag("p"):
print("Hello")Wrap the yield in try/finally and your cleanup runs even when the block raises — exactly the guarantee that makes context managers so valuable:
from contextlib import contextmanager
@contextmanager
def managed_resource():
print("acquire")
try:
yield "resource"
finally:
print("release") # runs even if the body raises
try:
with managed_resource() as r:
print("using", r)
raise ValueError("boom")
except ValueError:
print("handled error")Key idea
yield (inside finally) still runs. No leaked files, no dangling locks, no forgotten cleanup.Multiple managers
You can open several context managers in one with, separated by commas — handy for copying between two files. They enter left-to-right and exit right-to-left:
with open("in.txt") as src, open("out.txt", "w") as dst:
dst.write(src.read())
# both files are closed here, in reverse orderRecap & quick check
Key takeaways
- A context manager guarantees setup and cleanup around a block — cleanup runs even on error.
- Class-based: __enter__ (setup; return value goes to 'as') and __exit__ (teardown; gets exception info).
- __exit__ returning True suppresses the exception; False/None lets it propagate.
- @contextlib.contextmanager turns a generator into a manager: before yield = setup, after = teardown.
- Wrap yield in try/finally for guaranteed cleanup; combine multiple managers with commas in one with.
Quick check
1. What does a context manager guarantee?
2. Which two methods implement a class-based context manager?
3. In @contextmanager, what does the code before yield represent?
4. How do you guarantee cleanup runs even if the with-body raises?
5. What does returning True from __exit__ do?
That completes Phase 4 — Intermediate Python! 🎉 Next we enter Phase 5 — Advanced Python with Module 26 — Type Hints & Static Typing.