with open(...) as f: is the most common context manager most Python developers ever write, but rarely the last one they need. This lesson covers the __enter__/__exit__ protocol behind with, and contextlib.contextmanager, the generator-based shortcut for writing your own.
1The __enter__/__exit__ Contract Behind with
with EXPR as VAR: is defined, precisely, as: evaluate EXPR to get a context manager object, call its __enter__() method and bind the result to VAR, run the indented block, and then ā no matter how that block ends, whether it completes normally, returns, breaks, or raises an exception ā call the context manager's __exit__() method exactly once before control leaves the with statement.
That 'no matter how it ends' guarantee is the entire reason with exists: it's structurally identical to writing a try/finally, except the cleanup logic lives inside the reusable context manager object instead of being retyped at every call site. open() returns a file object that implements this protocol, which is why with open(path) as f: guarantees f.close() runs even if the code reading f raises partway through ā a plain f = open(path) without with offers no such guarantee.
__enter__'s return value becomes the as variable ā often, but not necessarily, self; some context managers (like certain database transaction wrappers) return a different, more specific object than the context manager itself, one specifically meant for use inside the block.
class Timer:
def __enter__(self):
import time
self.start = time.time()
return self # becomes the 'as' variable
def __exit__(self, exc_type, exc_value, traceback):
import time
print(f"Elapsed: {time.time() - self.start:.4f}s")
return False # False = don't suppress the exception
with Timer() as t:
print("doing work...")Elapsed: 0.0012s ā printed even if the block raises
2__exit__'s Three Arguments and Exception Suppression
__exit__(self, exc_type, exc_value, traceback) receives full information about any exception that occurred inside the with block: exc_type is the exception class (or None if the block completed without error), exc_value is the actual exception instance, and traceback is its traceback object. This is what lets a context manager behave differently depending on whether ā and how ā the block failed, not just run identical cleanup regardless.
The return value of __exit__ has special, specific meaning: if it evaluates to True, Python treats the exception as *handled* and does not propagate it any further ā execution resumes normally after the with block, as if nothing had gone wrong. If __exit__ returns False (or None, which is what happens if you don't explicitly return anything, since Python functions default to returning None), the exception continues propagating exactly as if the with statement weren't there.
This is a sharp, easy-to-misuse tool: swallowing exceptions silently is often a bug waiting to happen, hiding real failures from callers who have no idea something went wrong. The idiomatic use is narrow and deliberate ā check exc_type for a *specific* exception class you genuinely intend to suppress (as in SuppressValueError), and always return False for anything else so unrelated errors still propagate normally.
class SuppressValueError:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is ValueError:
print(f"Suppressed: {exc_value}")
return True # exception is swallowed here
return False # anything else propagates normally
with SuppressValueError():
raise ValueError("this never escapes the with block")
print("execution continues normally")Suppressed ā execution continues past the with block
3@contextmanager: A Generator Shortcut for Simple Cases
Writing a full class with __enter__ and __exit__ for a simple 'do setup, run the block, do cleanup' context manager is more ceremony than the logic usually deserves. contextlib.contextmanager lets you write the same behavior as a generator function with exactly one yield: everything before yield becomes __enter__'s logic, the value passed to yield becomes the as variable, and everything after yield ā critically, wrapped in a try/finally ā becomes __exit__'s cleanup logic.
The finally is not optional ceremony; it's what makes the generator-based context manager honor the same 'cleanup always runs' guarantee as the class-based version. Without it, an exception raised inside the with block would propagate straight out of the generator at the yield line, skipping whatever cleanup code comes after ā silently breaking the exact contract with exists to provide.
@contextmanager is idiomatic and heavily used throughout the standard library and popular frameworks (contextlib.suppress, database session helpers, temporary environment variable overrides) specifically because most context managers really are this simple: acquire something, yield control, release it ā and the generator syntax expresses that shape more directly than a class with two separate methods that have to agree on shared state via self.
from contextlib import contextmanager
import time
@contextmanager
def timer():
start = time.time()
try:
yield # code inside the 'with' block runs here
finally:
print(f"Elapsed: {time.time() - start:.4f}s")
with timer():
print("doing work...")yield splits setup from guaranteed (try/finally) teardown
4Step-by-Step Breakdown
with guarantees a file gets closed even if the code inside raises an exception. That guarantee comes from a two-method protocol ā let's implement it ourselves.
with calls __enter__ at the start of the block and __exit__ at the end ā guaranteed, even if an exception is raised inside the block.
Checkpoint: Is __exit__ guaranteed to run even if the code inside the with block raises an exception?
- āYes ā __exit__ always runs, exception or not, similar to a finally block
- āNo ā an exception skips __exit__ entirely
__exit__'s three parameters describe any exception that occurred. Returning True from __exit__ suppresses that exception entirely.
Checkpoint: What does returning True from __exit__ do to an exception that occurred inside the with block?
- āIt suppresses the exception ā code after the with block continues normally
- āIt re-raises the exception with additional context
contextlib.contextmanager turns a generator with exactly one yield into a context manager ā no class needed for simple cases.
You've now covered functions-that-wrap-functions, functions-that-pause, and objects-that-hook-into-language-statements ā descriptors take that last idea even further, letting an object control what happens on plain attribute access.
Implement Real Enter/Exit Hooks. Finish Timer.__exit__(): __exit__ always runs when the with block ends.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Wrap the yield in try/finally when writing a @contextmanager generator
Without it, an exception inside the with block skips your cleanup code entirely, silently breaking the guarantee that with exists to provide.
Only suppress specific, anticipated exception types from __exit__
Returning True unconditionally from __exit__ hides every error, including genuine bugs unrelated to what the context manager was designed to handle. Check exc_type against a specific class before suppressing.
Frequent Bugs
Writing a @contextmanager generator without a try/finally around yield, so an exception inside the with block causes the generator's cleanup code (after yield) to never execute.
Always structure a @contextmanager generator as: setup code, try: yield value, finally: cleanup code ā the finally guarantees cleanup runs regardless of what happens inside the with block.
Real-World Examples
A Temporary Working Directory Context Manager
A test suite needs to temporarily change the current working directory for the duration of a test, then reliably restore the original directory afterward, even if the test fails.
from contextlib import contextmanager
import os
@contextmanager
def temporary_cwd(path: str):
original = os.getcwd()
os.chdir(path)
try:
yield
finally:
os.chdir(original)
with temporary_cwd("/tmp/test_fixtures"):
run_test_that_reads_relative_files()