Context Managers and the with Statement
What with is actually doing, and building your own context managers for resource management.
Quick answer
A context manager sets something up and guarantees it is cleaned up: the with statement calls __enter__ at the start and __exit__ at the end, even if the block raises. contextlib.contextmanager lets you write one as a generator.
- Code before yield is setup; code after yield, in finally, is cleanup.
- The value after as is what __enter__ returns, or what the generator yields.
- Returning True from __exit__ swallows the exception; you rarely want that.
from contextlib import contextmanager
@contextmanager
def connection(name):
print("open", name)
try:
yield name.upper()
finally:
print("close", name)
with connection("db") as conn:
print("using", conn)
try:
with connection("cache"):
raise RuntimeError("boom")
except RuntimeError as error:
print("caught", error)Output
open db using DB close db open cache close cache caught boom
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
Lesson 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.
Lesson 31 → Closures and Scope — The LEGB RuleQuick check
An exception is raised inside a with block. Does __exit__ run?
HELP US IMPROVE
How was this lesson?
Your note is shared with the Chaduvuko team for review. Please don’t include personal or sensitive information.
Spotted a mistake or something unclear? Suggest a fix on GitHub or email us. Select the text first and it is quoted for you.
Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.
