Context Managers and the with Statement
What with is actually doing, and building your own context managers for resource management.
Guaranteed Cleanup, Even When Things Go Wrong
You met with open(...) as f: back in the Reading & Writing Files module, with a promise that it would be explained properly later — this is that module. The with statement exists to guarantee that some cleanup action (closing a file, releasing a lock, closing a database connection) happens no matter how the block inside exits — normally, via an early return, or because an exception was raised.
f = open("data.txt")
data = f.read()
process(data) # if this raises an exception, f.close() below NEVER RUNS — file stays open
f.close()with open("data.txt") as f:
data = f.read()
process(data) # even if this raises, the file is still closed correctly
# f.close() has already happened automatically hereThis is not a minor convenience — a leaked file handle, database connection, or lock in production code is a genuinely common category of real bug, and it is exactly the class of bug with makes structurally difficult to write in the first place.
__enter__ and __exit__
A with statement works with any object that implements two dunder methods: __enter__, called at the start of the block (its return value is what as binds to), and __exit__, called when the block ends — no matter how it ends.
class Timer:
def __enter__(self):
import time
self.start = time.time()
return self # this becomes the value bound by "as"
def __exit__(self, exc_type, exc_value, traceback):
import time
elapsed = time.time() - self.start
print(f"Elapsed: {elapsed:.3f}s")
return False # False = don't suppress any exception (see Part 03)
with Timer() as t:
total = sum(range(10_000_000))
# Elapsed: 0.412s <- printed automatically when the block ends__exit__ always receives three arguments describing any exception that occurred inside the block — exc_type, exc_value, and traceback — all three are None if the block completed normally with no exception at all.
What __exit__'s Return Value Actually Controls
This is the single most important and most misunderstood detail of the protocol: if __exit__ returns a truthy value, Python treats the exception as handled and suppresses it entirely — the exception simply vanishes, as if it never happened, and code after the with block continues executing normally.
class BadLogger:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is not None:
print(f"Something went wrong: {exc_value}")
return True # DANGER: this SUPPRESSES the exception entirely!
with BadLogger():
result = 10 / 0 # ZeroDivisionError happens...
print("Program continues normally")
# Something went wrong: division by zero
# Program continues normally <- the exception was silently swallowed, "result" was NEVER assigned!False (or nothing, which is the same as None — also falsy) from __exit__. Deliberately suppressing exceptions is a legitimate, narrow use case (for example, a context manager whose entire purpose is ignoring a specific known error type), but doing it accidentally — by returning a truthy value out of habit, or by the last statement inside __exit__ happening to evaluate truthy — silently hides real bugs and is extremely difficult to debug later, since the program simply continues as if nothing failed.Writing a Context Manager With a Generator Instead of a Class
Writing a full class with __enter__/__exit__ is verbose for simple cases. contextlib.contextmanager lets you write a context manager as a single generator function (generators were covered in the previous phase) — everything before yield becomes __enter__, the yielded value becomes what as binds to, and everything after yield becomes __exit__.
from contextlib import contextmanager
import time
@contextmanager
def timer():
start = time.time()
try:
yield # the "with" block's code runs here, at the yield point
finally:
elapsed = time.time() - start
print(f"Elapsed: {elapsed:.3f}s")
with timer():
total = sum(range(10_000_000))
# Elapsed: 0.412syield in a try/finally. If the code inside the with block raises an exception, that exception is raised at the yield statement itself inside your generator — without a finally, your cleanup code after yield would simply never run if an exception occurs, exactly defeating the purpose of using a context manager in the first place.A genuinely common real use — temporarily changing state and restoring it
from contextlib import contextmanager
import os
@contextmanager
def working_directory(path):
original = os.getcwd()
os.chdir(path)
try:
yield
finally:
os.chdir(original) # ALWAYS restored, even if the block raises
with working_directory("/tmp"):
print(os.getcwd()) # /tmp
print(os.getcwd()) # back to the original directory automaticallyCombining Several Resources in One with Statement
with open("input.txt") as infile, open("output.txt", "w") as outfile:
outfile.write(infile.read().upper())
# BOTH files are guaranteed closed here, even if writing raised an exceptionThis comma-separated form is equivalent to nesting two separate with statements — Python enters each context manager in order, and exits them in reverse order once the block ends, exactly like closing nested parentheses.
with db.connection() as conn, conn.lock():
conn.execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1")
# The lock is released first, THEN the connection is closed — reverse of entry orderA Connection Pool Exhaustion Incident at a Portland E-commerce Company
A checkout service starts returning "connection pool exhausted" errors under normal traffic. Investigation traces it to a function that manually acquires a database connection and releases it at the end — but a validation check earlier in the function occasionally raises an exception on malformed cart data, skipping the release call entirely.
def checkout(cart):
conn = pool.acquire()
validate_cart(cart) # raises ValueError on malformed data — happens under real traffic
conn.execute("INSERT INTO orders ...")
pool.release(conn) # NEVER REACHED if validate_cart raised — connection leaksdef checkout(cart):
with pool.connection() as conn: # pool.connection() is a context manager wrapping acquire/release
validate_cart(cart)
conn.execute("INSERT INTO orders ...")
# conn is released here NO MATTER WHAT — including if validate_cart raisedWhy manual acquire/release is considered a code smell in review
Every manually paired "acquire resource / release resource" pattern has exactly this failure mode: any code between the two calls that can raise an exception silently skips the release. The connection pool itself already exposed a context-manager-based pool.connection() for precisely this reason — reviewers at this company now flag any manual acquire()/release() pairing on sight, since a with-based alternative almost always already exists or can be written in a few lines using contextlib.contextmanager.
Four Misconceptions About Context Managers
5 Interview Questions — With Complete Answers
Context Manager Mistakes Beginners Make Constantly
Errors You Will Hit With Context Managers — And Exactly Why
🎯 Key Takeaways
- ✓with guarantees cleanup code runs no matter how a block exits — normal completion, early return, or an exception — unlike manually paired acquire/release calls, which skip cleanup if an exception occurs in between.
- ✓The protocol is __enter__ (runs at block start, return value bound by "as") and __exit__ (runs at block end, receiving exception info).
- ✓A truthy return from __exit__ SUPPRESSES the exception entirely — almost always return False or nothing, unless deliberate suppression is the actual intent.
- ✓@contextmanager lets you write a context manager as a generator: code before yield is __enter__, the yielded value is bound by "as", code after yield (wrapped in try/finally) is __exit__.
- ✓Multiple context managers can be combined in one with statement, separated by commas — they enter in order and exit in reverse order.
- ✓Prefer an existing or custom context manager over hand-written try/finally resource management wherever one is available — it centralizes correct cleanup in one place instead of relying on every call site getting it right.
What comes next
Module 31 closes out the Intermediate & Functional Python phase with closures and the LEGB scope rule — how Python actually resolves variable names, and the scoping bugs that confuse everyone once.
Module 31 → Closures and Scope — The LEGB RuleDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.