import io
with io.StringIO("hello\nworld\n") as fp:
print(fp.read().upper())HELLO
WORLD
with, match, and else BlocksThree often-underused Python blocks: with (context managers), match (structural pattern matching), and else after for/while/try (the “no exception” / “no break” block).
In this chapter you will learn to:
__enter__ and __exit__.@contextlib.contextmanager to make a generator into a context manager.contextlib.suppress, redirect_stdout, and ExitStack.else block — on for, while, and try.withA context manager is an object with __enter__ (setup, returns the as value) and __exit__ (teardown, called even on exception). The classic example is file I/O:
import io
with io.StringIO("hello\nworld\n") as fp:
print(fp.read().upper())HELLO
WORLD
with calls fp.__enter__(), binds the result to fp, runs the block, then calls fp.__exit__(...) no matter what — exception or clean exit.
A custom context manager. We’ll write one that monkey-patches sys.stdout.write to reverse each string it prints — it’s a contrived effect, but it shows the full lifecycle: setup on entry, teardown on exit, and exception handling in between.
import sys
class LookingGlass:
def __enter__(self):
self.original_write = sys.stdout.write
sys.stdout.write = self.reverse_write
return "JABBERWOCKY"
def reverse_write(self, text):
self.original_write(text[::-1])
def __exit__(self, exc_type, exc_val, exc_tb):
sys.stdout.write = self.original_write
if exc_type is ZeroDivisionError:
print("Please DO NOT divide by zero!")
return True # suppresses the exception
return False # propagates everything else
with LookingGlass() as label:
print("Alice")
print(label)
print("normal again")ecilA
YKCOWREBBAJ
normal again
Walking through the lifecycle:
__enter__(self) runs at the top of the with block. It saves the real sys.stdout.write so we can restore it later, swaps in our reverse_write replacement, and returns "JABBERWOCKY" — that return value is what as label binds.reverse_write(self, text) is the replacement. Each print(...) call now routes through here; we slice the text with [::-1] and forward the reversed string to the original write.__exit__(self, exc_type, exc_val, exc_tb) runs no matter how the block exits — clean fall-through, return, or exception. We restore the real write first, so cleanup happens before any further logic.None on a clean exit. If an exception passed through, they describe it. We special-case ZeroDivisionError, print a message, and return True — that tells Python “I handled it; don’t re-raise”. Returning False (or nothing) propagates the exception.with block, print("Alice") produces ecilA, and print(label) prints the reversed JABBERWOCKY. After the block, print("normal again") shows the real write is back.The general pattern: any class with __enter__ and __exit__ is a context manager. Use the class form when the manager carries non-trivial state; use the @contextmanager form (next) when a generator is enough.
@contextmanagerThe class form is verbose. contextlib.contextmanager turns a generator into a context manager, with yield as the pivot:
import contextlib
@contextlib.contextmanager
def looking_glass():
original_write = sys.stdout.write
def reverse_write(text):
original_write(text[::-1])
sys.stdout.write = reverse_write
try:
yield "JABBERWOCKY"
finally:
sys.stdout.write = original_write
with looking_glass() as label:
print("Alice")
print(label)
print("normal again")ecilA
YKCOWREBBAJ
normal again
Walking through the generator-as-CM:
@contextlib.contextmanager wraps the generator function in a class that supplies __enter__ and __exit__ automatically.yield is the setup phase — it runs when __enter__ is called.yield "JABBERWOCKY" is the pivot. The yielded value becomes the as label binding, and the generator pauses there for the duration of the with block.yield is the teardown phase — it runs when __exit__ is called.try/finally is the bit that can’t be skipped: if the with block raises, contextmanager re-throws the exception into the generator at the yield, and finally runs cleanup before the exception propagates.The general pattern: write the cleanup that has to happen no matter what inside finally; the yield is the boundary between setup and teardown.
The contextlib toolkit goes further:
import os, tempfile, contextlib
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "missing.txt")
with contextlib.suppress(FileNotFoundError):
os.remove(path)
print("done")done
suppress(FileNotFoundError) swallows the named exception (and only that one). Useful for “remove if it exists” patterns without the try/except/pass ritual.
contextlib.redirect_stdout and redirect_stderr swap a stream temporarily:
import io
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
print("captured!")
buf.getvalue()'captured!\n'
Walking through the redirect:
io.StringIO() builds an in-memory writable buffer — same .write() interface as a real file, but the bytes go into RAM.contextlib.redirect_stdout(buf) is the context manager: on entry, it replaces sys.stdout with buf; on exit, it puts the original sys.stdout back. Even if the body raises.with, print("captured!") writes to buf instead of the terminal. The output you’d normally see is swallowed.buf.getvalue() returns the accumulated text — 'captured!\n'. Useful for testing functions that print, or for capturing third-party output that doesn’t expose a clean API.contextlib.ExitStack lets you stack a dynamic number of context managers — useful when the count isn’t known at write time:
import io
from contextlib import ExitStack
sources = ["one\n", "two\n", "three\n"]
with ExitStack() as stack:
files = [stack.enter_context(io.StringIO(s)) for s in sources]
contents = [f.read() for f in files]
contents['one\n', 'two\n', 'three\n']
Walking through the stack:
with ExitStack() as stack: opens a single outer context manager that will manage many inner ones.stack.enter_context(cm) enters cm now (calls its __enter__) and registers its __exit__ to run when the ExitStack block exits. The return value is what the inner context manager yielded.StringIO objects — but sources could be an unknown-length iterable read at runtime. Nested with would require a fixed shape; ExitStack doesn’t.The general pattern: when the number of context managers is dynamic (an unknown count of files, locks, or transactions), reach for ExitStack instead of a tower of nested withs.
Day to day, the @contextmanager form is what most Python code writes. The class form is reserved for libraries and the rare context manager that needs internal state.
else blocksPython has three else clauses you may have missed.
for/else — the else runs when the loop completes without a break:
items = [1, 2, 3, 4]
target = 5
for item in items:
if item == target:
print("found")
break
else:
print("not found")not found
Walking through the control flow:
items. target is 5, which isn’t in the list, so item == target is never true and break never runs.for loop falls through naturally (no break), Python runs the attached else block — so we get not found.target had been 3, the break would have fired on the third iteration; else would have been skipped entirely.The general pattern: for/else is “I searched and didn’t find it” without a flag variable. Read it as for ... else / not-broken-out.
while/else — the same idea: else runs when the condition becomes false (no break):
n = 5
while n > 0:
n -= 1
else:
print(f"loop done, n={n}")loop done, n=0
try/else — the else runs if the try block raised no exception. Use it to keep the success-path logic out of the try:
import json
text = '{"a": 1}'
try:
data = json.loads(text)
except ValueError:
print("bad JSON")
else:
print("parsed:", data)
finally:
print("cleanup")parsed: {'a': 1}
cleanup
Walking through the four-block dance:
try runs json.loads(text). If it raises ValueError, control jumps to except; otherwise, it falls through.except ValueError: only runs on a parse failure. If you put the print("parsed:", data) here it would execute on success too — that’s a common mistake.else: runs only when the try succeeded and no exception was caught. It’s where the “use the result” logic belongs — keeping it out of try narrows what except catches by accident.finally: always runs — success, exception, even early return. It’s where unconditional cleanup goes.The general pattern: keep try minimal (just the call that might fail), put the success-path logic in else, put cleanup in finally. Each block has one job.
The with statement is Python’s universal resource management tool. @contextmanager turns a generator into a context manager with try/yield as the pivot point. The else clause on loops and try blocks expresses “success path” logic cleanly — without extra flags.
transaction context manager — class form and generator formDatabase-style transactions are the textbook context-manager case: take a snapshot on entry, commit on a clean exit, roll back on an exception. We’ll write it twice — once with __enter__ / __exit__, once with @contextmanager — so the two forms can be compared side by side.
Step 1: a tiny in-memory store and a class-form transaction. The store keeps the data in a dict and snapshots itself on begin(). The context manager calls begin on entry and commit or rollback on exit:
class Store:
def __init__(self) -> None:
self.records: dict[int, str] = {}
self._snapshot: dict[int, str] | None = None
def begin(self) -> None:
self._snapshot = dict(self.records)
def commit(self) -> None:
self._snapshot = None
def rollback(self) -> None:
if self._snapshot is not None:
self.records = self._snapshot
self._snapshot = None
def __setitem__(self, k: int, v: str) -> None:
self.records[k] = v
class Transaction:
def __init__(self, store: Store) -> None:
self.store = store
def __enter__(self) -> Store:
self.store.begin()
return self.store
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
self.store.commit()
else:
self.store.rollback()
return False # never suppress; let exceptions propagate
s = Store()
with Transaction(s) as tx:
tx[1] = "Alice"
tx[2] = "Bob"
s.records{1: 'Alice', 2: 'Bob'}
__enter__ returns the store so as tx binds it. __exit__ receives (exc_type, exc_val, exc_tb) — None triplet on a clean exit, the exception details on failure. Returning False (or omitting return) tells Python “I didn’t handle this; let it propagate” — exactly what a transaction should do for unexpected errors.
s = Store()
s[0] = "before" # outside the transaction
with Transaction(s) as tx:
tx[1] = "uncommitted"
raise RuntimeError("something failed")--------------------------------------------------------------------------- RuntimeError Traceback (most recent call last) Cell In[11], line 5 1 s = Store() 2 s[0] = "before" # outside the transaction 3 with Transaction(s) as tx: 4 tx[1] = "uncommitted" ----> 5 raise RuntimeError("something failed") RuntimeError: something failed
s.records # rollback restored the pre-transaction state{0: 'before'}
The exception propagated past __exit__, but only after rollback ran — tx[1] = "uncommitted" was undone. The pre-transaction s[0] = "before" is still there because it was outside the with block.
Step 2: the same context manager via @contextmanager. A generator function with one yield collapses the class form to half the code:
import contextlib
@contextlib.contextmanager
def transaction(store: Store):
store.begin()
try:
yield store
store.commit()
except Exception:
store.rollback()
raise
s = Store()
with transaction(s) as tx:
tx[1] = "Alice"
s.records{1: 'Alice'}
Setup before yield; teardown wrapped in try/except. yield store is where the with block runs — pause point. On clean exit the generator resumes and runs commit. On exception, control jumps to except, which rolls back and re-raises (the bare raise) so the caller still sees the error. The class form’s three methods are now one function with one try/except.
Step 3: pair the context manager with try/except/else/finally on the call site. The transaction handles its own commit/rollback; the caller often wants to log success vs failure separately. try/except/else on top of the with is the clean shape:
def apply_changes(store: Store, changes: dict[int, str]) -> str:
try:
with transaction(store) as tx:
for k, v in changes.items():
if v == "FAIL":
raise ValueError(f"cannot store FAIL for key {k}")
tx[k] = v
except ValueError as e:
return f"rolled back: {e}"
else:
return f"committed {len(changes)} change(s)"
finally:
# always runs — close handles, release locks, log timing
pass
s = Store()
[apply_changes(s, {1: "Alice", 2: "Bob"}),
apply_changes(s, {3: "Carol", 4: "FAIL"}),
s.records]['committed 2 change(s)',
'rolled back: cannot store FAIL for key 4',
{1: 'Alice', 2: 'Bob'}]
try is the small region that might raise (the whole with block, where commit/rollback happens). except ValueError catches the failure path — and crucially, the with already rolled back before this line runs. else runs only on the success path, after the transaction committed cleanly. finally runs in both cases — placeholder here for the kind of unconditional cleanup the chapter mentioned. Each block has one job.
The build threads three of the chapter’s tools together: __enter__/__exit__ for the class-form context manager (Step 1), @contextmanager plus try/except/raise for the generator form (Step 2), and try/except/else/finally on the call site to separate success-path logic from rollback handling (Step 3).
A logging CM. Write a context manager log_block(name) that prints "enter <name>" on entry, "exit <name>" on exit, and "raised <exc_type>" if an exception passed through.
Suppress vs. except. Replace a try/except FileNotFoundError: pass block with contextlib.suppress. Compare clarity.
Search with for/else. Rewrite a search loop using for/else. Then rewrite it again using next(iter, default) from a generator expression. Which reads better?
ExitStack. Use contextlib.ExitStack to open N files (where N is a runtime value) and ensure all are closed even on exception.
try/else for clarity. Find a try/except block in your code that has follow-up logic in the try. Move that logic to else. Did readability improve?
Beazley, Python Distilled §3 (“Program Structure and Control Flow”) collects the operational try / finally / with idioms that show up in production Python — context managers for locks, transactions, and resource pools.
ExceptionGroupPython 3.11 added ExceptionGroup and the except* syntax for catching by type without unbundling the rest — exactly what asyncio.TaskGroup needs.
try:
raise ExceptionGroup(
"two problems",
[ValueError("bad value"), TypeError("bad type")],
)
except* ValueError as eg:
print("values:", [str(e) for e in eg.exceptions])
except* TypeError as eg:
print("types:", [str(e) for e in eg.exceptions])values: ['bad value']
types: ['bad type']
Both except* clauses run; you don’t have to choose one. For everyday single-error code, plain except is still right — ExceptionGroup is for genuinely concurrent or batch failures.
The control flow of try / except / else / finally:
flowchart TB
A["try block"] --> B{"raised?"}
B -- no --> C["else block"]
B -- yes --> D["matching except"]
C --> E["finally"]
D --> E
E --> F["continue"]
else runs only on the success path; finally always runs. That’s why cleanup belongs in finally and the use the result logic belongs in else — never in the try itself.
with is Python’s universal teardown — class-form or @contextmanager-form. match (covered in Chapter 14 and Chapter 15) destructures by shape. else on loops and try blocks gives a clean “success path” without a flag variable. Three small features that quietly add up.
Next, Chapter 31 steps back to the conceptual chapter on concurrency: threads vs. processes vs. coroutines, and why each one solves a different problem.