Context managers
Setup and cleanup that always run: with, contextlib, transactions and atomic writes.
Create a free account Try part of it here
Why it matters
Open a file and forget to close it; take a lock and crash before releasing it; start a database change and fail halfway through. These bugs are easy to write and hard to find, because they only bite when something else has already gone wrong. Context managers put the setup and the cleanup in one place, and the with statement guarantees the cleanup runs. You've used them with open(); this lesson shows how to write your own.
What you'll be able to do
- Explain what a with block guarantees, even when the code inside fails
- Write a context manager as a class with __enter__ and __exit__, and with contextlib.contextmanager
- Use suppress, ExitStack and database transactions
- Write files safely, so a crash never leaves a half-written file behind
What it covers
- What with guarantees
- Writing one as a class
- The shortcut: contextlib.contextmanager
- Ready-made managers
- Change a setting, then put it back
- Where context managers appear
- Common mistakes
- Worked example: writing a file atomically
What with guarantees
with pairs a setup with a cleanup. Whatever happens inside the block, whether it finishes normally, returns early or raises an exception, the cleanup runs:
with open("notes.txt", "w") as f: f.write("first line\n") print("closed after the block:", f.closed) try: with open("notes.txt", "a") as f: f.write("second line\n") raise RuntimeError("something failed mid-write") except RuntimeError as e: print("error:", e, "| file closed anyway:", f.closed) print(open("notes.txt").read())
Without with, you'd need a try/finally around every use to be sure the file gets closed. A context manager is any object that knows its own setup and cleanup. with calls them at the right moments.
Writing one as a class
with thing as x: calls thing.__enter__() at the start; its return value becomes x. At the end it calls thing.__exit__(exc_type, exc, traceback). The three arguments describe the exception that ended the block, or are all None if it ended normally.
import time class Timer: def __init__(self, label): self.label = label def __enter__(self): self.start = time.perf_counter() return self def __exit__(self, exc_type, exc, tb): self.seconds = time.perf_counter() - self.start status = "failed" if exc_type else "ok" print(f"{self.label}: {self.seconds * 1000:.1f} ms ({status})") return False # don't swallow the exception with Timer("sum of squares") as t: total = sum(n * n for n in range(200_000)) print(total, t.seconds > 0) try: with Timer("dividing"): 1 / 0 except ZeroDivisionError: print("the error still reached us")
If __exit__ returns True, the exception is swallowed and the program carries on after the block. That's almost never what you want, so return False (or nothing) unless the whole point of the manager is to ignore a specific error.
Step through to see the order: __enter__ runs first and its return value becomes status, the block runs, then __exit__ runs with None for the error.
The shortcut: contextlib.contextmanager
Most context managers are easier to write as a generator with one yield. Everything before the yield is the setup, the yielded value is what as receives, and everything after is the cleanup. Put the yield in a try/finally so the cleanup runs even on an error:
import os from contextlib import contextmanager @contextmanager def working_dir(path): before = os.getcwd() os.makedirs(path, exist_ok=True) os.chdir(path) try: yield path finally: os.chdir(before) print(os.getcwd()) with working_dir("reports") as where: open("june.txt", "w").write("ok") print("inside:", os.getcwd().endswith(where)) print("back:", os.getcwd(), os.path.exists("reports/june.txt"))
Without the finally, an exception inside the block would skip the chdir back and leave the program in the wrong folder. That's exactly the kind of bug context managers exist to prevent.
from contextlib import contextmanager @contextmanager def tag(name): print(f"<{name}>", end="") try: yield finally: print(f"</{name}>", end="") with tag("b"): print("hi", end="") print()
Show the answer
<b>hi</b>
The code before yield runs as the block starts, the block prints hi, and the finally part runs as it ends.
Ready-made managers
The standard library has several you'll reach for:
import os import sqlite3 from contextlib import ExitStack, suppress with suppress(FileNotFoundError): os.remove("does-not-exist.tmp") print("no error: suppress ignored that one kind of exception") names = ["a.txt", "b.txt", "c.txt"] with ExitStack() as stack: files = [stack.enter_context(open(n, "w")) for n in names] for f, n in zip(files, names): f.write(n) print("all closed:", all(f.closed for f in files)) db = sqlite3.connect(":memory:") db.execute("CREATE TABLE stock (sku TEXT PRIMARY KEY, qty INTEGER CHECK (qty >= 0))") db.execute("INSERT INTO stock VALUES ('TE-101', 3)") try: with db: db.execute("UPDATE stock SET qty = qty + 5 WHERE sku = 'TE-101'") db.execute("UPDATE stock SET qty = qty - 20 WHERE sku = 'TE-101'") except sqlite3.IntegrityError as e: print("rolled back:", e) print(db.execute("SELECT qty FROM stock").fetchone())
suppress(SomeError)ignores one specific exception. Use it only when the error genuinely doesn't matter.ExitStackmanages any number of context managers, even when you don't know how many in advance. Everything entered is cleaned up when the block ends.- A sqlite3 connection used with
withis a transaction: if the block raises, every change in it is undone (rolled back); if it finishes, all of them are saved (committed). Here the second update would make stock negative, so both updates are undone and the quantity stays 3.
Change a setting, then put it back
Write a context manager override(settings, key, value) with @contextmanager: inside the block settings[key] is value; afterwards it's restored to what it was, even if the block raises. If the key didn't exist before, remove it afterwards.
from contextlib import contextmanager @contextmanager def override(settings, key, value): yield config = {"currency": "USD"} with override(config, "currency", "EUR"): print(config["currency"]) print(config["currency"])
Where context managers appear
Once you recognize the shape, "set something up, do some work, always clean up", you'll see context managers all over real code:
- Files and network connections:
open,requests.Session(),smtplib.SMTP,socketobjects. - Locks in threaded code:
with lock:takes the lock and always releases it, so one crash can't freeze every other thread. - Databases: sessions and transactions in sqlite3 and SQLAlchemy, where the block's changes are saved together or not at all.
- Temporary state:
tempfile.TemporaryDirectory()deletes its folder afterwards;decimal.localcontext()changes precision for one calculation; tests usepytest.raisesand patching helpers to change something only for the duration of a block. - Timing and profiling: a timer around a block, as you built above.
A useful test for your own code: whenever you write a try/finally whose finally undoes something the try set up, ask whether it should be a context manager. Naming the pattern once means every caller gets the cleanup right without thinking about it.
Common mistakes
A few slips catch almost everyone the first time:
- Using the resource after the block.
with open(...) as f:closesfat the end of the block, so reading from it on the next line fails with "I/O operation on closed file". Do the work inside, or keep the data you read, not the file. - A @contextmanager generator that yields twice, or never. It must yield exactly once: that one yield is where the block runs. Yielding inside a loop raises "generator didn't stop".
- Forgetting that the block's exception arrives at the yield. Code after a bare
yielddoesn't run when the block fails. Cleanup belongs infinally. - Opening many files in a loop without closing them. Opening thousands of files without
withcan exhaust what the operating system allows. Either close each one in its ownwith, or hold them in anExitStack. - Swallowing errors by accident. A class whose
__exit__returns a true value hides every exception raised in the block, so bugs vanish silently. Return False, or nothing at all, unless hiding one specific error is the whole point.
Worked example: writing a file atomically
If a program crashes while overwriting a file, the file can be left half-written: the old data gone, the new data incomplete. Write to a temporary file first, and only replace the real one when everything succeeded.
import os import tempfile from contextlib import contextmanager @contextmanager def atomic_write(path): folder = os.path.dirname(os.path.abspath(path)) fd, temp_path = tempfile.mkstemp(dir=folder, suffix=".tmp") try: with os.fdopen(fd, "w") as f: yield f os.replace(temp_path, path) except BaseException: os.remove(temp_path) raise with atomic_write("prices.json") as f: f.write('{"TE-101": 200}') print(open("prices.json").read()) try: with atomic_write("prices.json") as f: f.write('{"TE-101": 17') raise RuntimeError("crashed halfway") except RuntimeError: pass print(open("prices.json").read()) print([n for n in os.listdir(".") if n.endswith(".tmp")])
tempfile.mkstemp creates a uniquely named temporary file in the same folder. The block writes to it. Only if the block finishes does os.replace swap it in for the real file. That step is atomic: anyone reading the file sees either the old version or the new one, never half of each. If anything fails, the temporary file is deleted and the exception passes on, and prices.json still holds the last good data. Save files, caches and configuration this way whenever a half-written file would hurt.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
In a @contextmanager generator, why put the yield inside try/finally?