Here are the three ways to close a file that you may have seen before:
f = open("data.txt") # 1. remember to call f.close()
...
try: # 2. try/finally
f = open("data.txt")
finally:
f.close()
with open("data.txt") as f: # 3. the professional way
...
The third one is what everyone uses. The secret is that with works with any object that follows a small set of rules, not just files. A file just happens to be the first such object you met. Objects that follow these rules are called context managers, and once you understand them you can use the same clean pattern for database connections, timers, temporary settings, safe file writing and much more.
This is also where generators and decorators come together: the shortest way to write your own with helper is a generator with a decorator on top.
Here is what we will cover:
- Why cleanup is harder than it looks
- The two methods behind
with:__enter__and__exit__ - How to write your own context manager as a class
- What
asgives you, and what__exit__sees when the block crashes - A timer for any block of code
@contextlib.contextmanager, the short way to write one- The
contextlibtools you'll use:suppress,closing,redirect_stdout - Using several managers in one
with - Real patterns: rollbacks, a temporary working directory, safe file writes
ExitStack, for when you don't know in advance how many managers you need
Setting Up
We'll keep our practice files in their own folder, so they don't clutter your working directory. This cell creates a small sales file with four regions:
import os
from pathlib import Path
os.makedirs("context_demo", exist_ok=True)
with open("context_demo/sales.csv", "w", encoding="utf-8") as f:
f.write("region,units\n")
f.write("north,120\n")
f.write("south,95\n")
f.write("east,140\n")
f.write("west,88\n")
print("Sample files ready.")
Sample files ready.
Part 1: The Problem, Cleanup That Has to Happen
When you open a file, the operating system gives your program a file handle (also called a file descriptor). Think of it as a numbered ticket that says "this program is currently using this file". A program can only hold a limited number of these tickets at once, which is why you must give each one back with close().
Analogy: a coat-check counter has a limited number of hooks. Every time you check in a coat, you get a ticket. If people keep checking in coats and never collect them, the counter eventually runs out of hooks and has to turn new people away.
Here's what happens when something goes wrong before you get the chance to close the file:
f = open("context_demo/sales.csv", encoding="utf-8")
try:
f.readline() # skip the header row
units = int("not a number") # a bad row in the data, so this raises
f.close() # never reached
except ValueError as e:
print("Error:", e)
print("Is the file still open?", not f.closed)
f.close() # clean up so the rest of the notebook is tidy
Error: invalid literal for int() with base 10: 'not a number'
Is the file still open? True
The close() call was right there in the code. It just never ran, because the error jumped straight past it to the except block. The file stayed open.
One file left open on your laptop does no harm. But a web server that leaves one open for every request eventually runs out of handles and stops accepting connections. That's how small cleanup mistakes turn into real outages.
The finally Fix
A finally block always runs, whether the code above it succeeded or crashed:
f = open("context_demo/sales.csv", encoding="utf-8")
try:
f.readline()
units = int("not a number")
except ValueError as e:
print("Error:", e)
finally:
f.close() # runs no matter what
print("Is the file still open?", not f.closed)
Error: invalid literal for int() with base 10: 'not a number'
Is the file still open? False
That works, but it's a lot of extra lines around one line of real work. You also have to remember it every single time. And the f.close() you must remember sits far away from the open() that made it necessary.
Analogy: it's like leaving yourself a sticky note saying "turn off the stove" on the front door. It might work, but the reminder is far from the stove, and one day you'll forget to write the note.
The with Fix
with moves the cleanup into the file object itself. Here's the same crash inside a with block:
try:
with open("context_demo/sales.csv", encoding="utf-8") as f:
f.readline()
units = int("not a number") # the same crash as before
except ValueError as e:
print("Error:", e)
print("Is the file still open?", not f.closed)
Error: invalid literal for int() with base 10: 'not a number'
Is the file still open? False
The file closed on its own, even though the block crashed. You didn't have to remember anything.
Analogy: with is like a hotel room with an automatic check-out. However you leave (calmly at the end of your stay, or in a rush because the fire alarm went off), the hotel checks you out and takes back the key card. You can't forget, because it isn't your job anymore.
This Pattern Is Everywhere
Nothing about this pattern is specific to files. It shows up anywhere you take something and have to give it back:
| You acquire | You must release | If you forget |
|---|---|---|
| An open file | close() |
Leaked descriptors; unflushed writes |
| A database connection | close() |
The connection pool drains and the app stalls |
| A lock | release() |
Every other thread waits forever |
| A changed setting | Restore the old value | The rest of the program silently misbehaves |
| A started transaction | commit() or rollback() |
Half-written data |
| A temporary directory | Delete it | Disk fills up |
Every row has a setup step and a cleanup step, where the cleanup has to happen even if the part in the middle crashes. That's the job of a context manager.
Part 2: What with Actually Does
with isn't file syntax. Any object that has two special methods, __enter__ and __exit__, can be used in a with statement. Python calls a set of methods like this a protocol, and these two make up the context manager protocol.
Analogy: a protocol is like the shape of a power socket. The socket doesn't care what brand of device you plug in. As long as the plug has the right shape (here: an __enter__ and an __exit__ method), it fits.
These are dunder methods (short for "double underscore") that Python calls for you, just like __init__, __iter__ and __next__.
This:
with open("data.txt") as f:
body
is roughly a shorter way to write this:
manager = open("data.txt")
f = manager.__enter__()
try:
body
finally:
manager.__exit__(exception_type, exception_value, traceback)
(exception_type, exception_value and traceback describe the error, if there was one. Part 5 looks at them.)
with open("data.txt") as f: what Python does
body
1. manager = open("data.txt")
2. f = manager.__enter__() ← SETUP ("check in")
3. run the body ← your code
4. manager.__exit__(...) ← CLEANUP ("check out")
ALWAYS runs, even if step 3 crashed
So the try/finally we wrote by hand didn't disappear. Python writes it for you, and the object supplies the cleanup.
First, let's check that a file object really has those two methods:
f = open("context_demo/sales.csv", encoding="utf-8")
print("has __enter__:", hasattr(f, "__enter__"))
print("has __exit__ :", hasattr(f, "__exit__"))
f.close()
has __enter__: True
has __exit__ : True
We can even call the two methods ourselves, with no with statement at all:
f = open("context_demo/sales.csv", encoding="utf-8")
same_file = f.__enter__() # what 'as' would have bound
print("__enter__ returned the file itself:", same_file is f)
print("first line:", same_file.readline().strip())
f.__exit__(None, None, None) # the three arguments come later
print("closed:", f.closed)
__enter__ returned the file itself: True
first line: region,units
closed: True
__enter__ returned the file itself (so same_file is f is True), and __exit__ closed it. Never write that in real code. It's only here to prove that with is nothing more than ordinary method calls.
If an object doesn't have those methods, with tells you plainly:
try:
with 42 as number: # deliberately wrong: 42 is not a context manager
print(number)
except TypeError as e:
print("TypeError:", e)
TypeError: 'int' object does not support the context manager protocol
This error means the object has no __enter__/__exit__, which usually means you passed the wrong thing. A common cause is giving with a file path (a string) when you meant open(path).
Part 3: Writing Your Own Context Manager
You only need two methods. The simplest useful one announces when a block starts and ends:
class Section:
def __init__(self, name):
self.name = name
def __enter__(self):
print(f"[start] {self.name}")
return self # this is what 'as' binds
def __exit__(self, exc_type, exc_value, traceback):
print(f"[end] {self.name}")
with Section("loading data"):
print(" ...reading rows...")
print(" ...cleaning rows...")
print("back outside the block")
[start] loading data
...reading rows...
...cleaning rows...
[end] loading data
back outside the block
Read the output from top to bottom and you can see the order:
__enter__runs ([start] loading data).- The body of the
withblock runs (the two...lines). __exit__runs ([end] loading data).- The code after the block runs.
Analogy: like the curtain at a theatre. The curtain opens (__enter__), the play happens (the body), and the curtain closes (__exit__). You never have to remind the curtain to close.
A Database Connection That Always Closes
Real programs often talk to a database. You connect, run some queries, and then must close the connection. Here's a pretend database class with that guarantee built in:
class Database:
# A stand-in for a real driver such as sqlite3 or psycopg2.
def __init__(self, name):
self.name = name
self.queries = []
def run(self, query):
self.queries.append(query)
print(f"[DB] running: {query}")
def __enter__(self):
print(f"[DB] connected to {self.name!r}")
return self
def __exit__(self, exc_type, exc_value, traceback):
print(f"[DB] closed after {len(self.queries)} queries")
with Database("analytics") as db:
db.run("SELECT count(*) FROM users")
db.run("SELECT * FROM orders LIMIT 10")
[DB] connected to 'analytics'
[DB] running: SELECT count(*) FROM users
[DB] running: SELECT * FROM orders LIMIT 10
[DB] closed after 2 queries
The caller writes one with line and can't get the cleanup wrong. They don't even need to know how closing works.
Analogy: a phone that automatically hangs up when you put it down. You just talk; the hanging up takes care of itself.
The Real Test: What Happens When the Block Crashes?
try:
with Database("analytics") as db:
db.run("SELECT * FROM users")
db.run("UPDATE users SET last_seen = now()")
raise RuntimeError("network dropped mid-query")
except RuntimeError as e:
print("caught outside the block:", e)
[DB] connected to 'analytics'
[DB] running: SELECT * FROM users
[DB] running: UPDATE users SET last_seen = now()
[DB] closed after 2 queries
caught outside the block: network dropped mid-query
Look at the order. [DB] closed printed first, then our except ran. The connection closed on the way out, and the error still reached the caller, so nothing was hidden.
That's the default, and it's the right one. A context manager's job is to clean up. It doesn't get to decide that your error didn't matter.
Part 4: What as Gives You
as name gives you whatever __enter__ returns. Not the manager, and not the object you passed in. Most managers return self, which is why it usually looks like the same object.
Analogy: when you check in at a hotel, the receptionist hands you a key card. What you get "as" a result of checking in is whatever the receptionist chooses to hand you: usually the key card, but it could be a welcome pack, or nothing at all.
Forget that return and you get a confusing bug:
class Broken:
def __enter__(self):
print("opening")
# oops: no return statement
def __exit__(self, exc_type, exc_value, traceback):
print("closing")
with Broken() as thing:
print("thing is:", thing)
opening
thing is: None
closing
__enter__ returned None, as every function without a return does, so thing is None. Nothing has gone wrong yet. The error shows up at the next line that tries to use it:
try:
with Broken() as thing:
thing.run("SELECT 1") # thing is None, so this fails
except AttributeError as e:
print("AttributeError:", e)
opening
closing
AttributeError: 'NoneType' object has no attribute 'run'
If you see 'NoneType' object has no attribute ... inside a with block, check for a missing return self first.
__enter__ Can Return Anything Useful
Returning self is a habit, not a rule. __enter__ can hand the block anything useful. This one hands over an empty list to collect things into, then reports on it afterwards:
class Collected:
def __enter__(self):
self.items = []
return self.items # the block gets the list, not the manager
def __exit__(self, exc_type, exc_value, traceback):
print(f"collected {len(self.items)} items: {self.items}")
with Collected() as bucket:
bucket.append("north")
bucket.append("south")
bucket.append("east")
collected 3 items: ['north', 'south', 'east']
The block worked directly with the list (bucket), and when the block ended, __exit__ reported what was collected.
Part 5: __exit__ and Errors
We've been writing __exit__(self, exc_type, exc_value, traceback) and ignoring the three arguments. Here's what they hold:
- When the block finishes without an error, all three are
None. - When the block raises an error, they are the error's class (like
ZeroDivisionError), the error object itself (which holds the message), and its traceback (the record of where it happened). Python'ssys.exc_info()gives you the same three things.
Analogy: think of a lifeguard at the exit of a pool. When each swimmer leaves, the lifeguard notes either "swim went fine" (three Nones) or "this swimmer got a cramp, here's what happened and where" (the three error details).
class Watcher:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is None:
print("__exit__: block finished cleanly")
else:
print(f"__exit__: block raised {exc_type.__name__} -> {exc_value}")
with Watcher():
print("doing fine")
print()
try:
with Watcher():
total = 100 / 0
except ZeroDivisionError:
print("...and it still reached us out here")
doing fine
__exit__: block finished cleanly
__exit__: block raised ZeroDivisionError -> division by zero
...and it still reached us out here
So __exit__ gets to see the error. That's what makes rollbacks possible: the manager can tell "the block worked, keep the changes" apart from "the block failed, undo everything". We'll build exactly that in Part 11.
Returning True Swallows the Error
__exit__ has one more power. If it returns True, Python treats the error as handled, and the error never leaves the with block:
class Swallow:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
print("__exit__ saw:", exc_type.__name__)
return True # "I handled it"
with Swallow():
print("about to fail")
result = 100 / 0
print("this line never runs")
print("we get here: the exception never escaped the block")
about to fail
__exit__ saw: ZeroDivisionError
we get here: the exception never escaped the block
Notice that "this line never runs" didn't print. The error still ended the block at the line that failed. Returning True only stopped the error from travelling any further.
the with block raises an error
│
▼
__exit__(exc_type, exc_value, traceback) runs
│
├── returns True (or anything truthy) → error is SWALLOWED, program carries on after the block
│
└── returns False / None / nothing → error CONTINUES to the caller (the normal, safe default)
Be careful with this. Swallow doesn't only swallow the error you had in mind. It swallows every error, including ones you never thought about:
with Swallow():
total = int("120") + int("nrth") # a typo in a region name
print("total:", total)
print("no crash, no total, no clue anything went wrong")
__exit__ saw: ValueError
no crash, no total, no clue anything went wrong
A typo quietly produced nothing at all. If that block had been working out a monthly revenue figure, the report would be missing a number and nobody would know why.
Analogy: a manager who hides every complaint from the boss, not just the trivial ones. Things seem calm, until something serious goes unnoticed.
Swallowing Only One Kind of Error
A safer version swallows one kind of error and lets the rest through. Its __exit__ returns True for "stop this error here" and False for "let it continue".
issubclass(exc_type, FileNotFoundError) asks "is this error a FileNotFoundError, or one of its child error types?":
class IgnoreMissingFile:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is None:
return False
if issubclass(exc_type, FileNotFoundError):
print(f"ignoring: {exc_value.filename} was already gone")
return True # handled
return False # anything else goes through
with IgnoreMissingFile():
os.remove("context_demo/never_existed.txt")
print("cleanup finished\n")
try:
with IgnoreMissingFile():
os.remove(12345) # a bug: 12345 is not a filename
except TypeError as e:
print("TypeError got through, as it should:", e)
ignoring: context_demo/never_existed.txt was already gone
cleanup finished
TypeError got through, as it should: remove: path should be string, bytes or os.PathLike, not int
- The first block tried to delete a file that doesn't exist. That's a
FileNotFoundError, so it was quietly ignored. (For a cleanup task, "the file was already gone" is perfectly fine.) - The second block had a real bug (passing a number instead of a filename). That's a
TypeError, so__exit__returnedFalseand the error got through, as it should.
The Accidental Swallow: Truthy and Falsy Values
Here's the catch: Python doesn't only check for the exact value True. It treats lots of values as true or false, the same way an if does:
- Non-zero numbers and non-empty strings count as true.
0,Noneand empty things (like""or[]) count as false.
People call these truthy and falsy values. bool() shows how Python sees each one:
for value in [True, 2, "done", False, 0, None, ""]:
print(f"{value!r:8} counts as {bool(value)}")
True counts as True
2 counts as True
'done' counts as True
False counts as False
0 counts as False
None counts as False
'' counts as False
So any truthy value returned from __exit__ swallows the error. That makes this bug easy to write without noticing. The __exit__ below ends with a return that was only meant to report a count:
class Cleanup:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
removed = ["batch_1.tmp", "batch_2.tmp"]
print(f"cleaned up {len(removed)} temp files")
return len(removed) # meant as a count, but 2 is truthy!
with Cleanup():
1 / 0
print("the ZeroDivisionError vanished, and nothing said so")
cleaned up 2 temp files
the ZeroDivisionError vanished, and nothing said so
return len(removed) returned 2, and 2 is truthy, so Python decided the error was handled. A ZeroDivisionError vanished without a trace.
The rule: end an __exit__ with return True, return False, or no return at all. Anything else risks swallowing errors by accident. If you don't want to suppress anything, just don't return a value: you get None, which is falsy.
Part 6: A Timer for Blocks of Code
A decorator like @execution_timer can time a whole function. But if a function does three things and only one of them is slow, the decorator can't tell you which.
A context manager times a block, and a block can be as big or as small as you like:
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_value, traceback):
self.elapsed = time.perf_counter() - self.start
print(f"{self.label}: {self.elapsed:.1f}s")
with Timer("loading sales data"):
time.sleep(0.5) # stand-in for the real work
loading sales data: 0.5s
time.perf_counter() is a stopwatch made for measuring short durations accurately. __enter__ starts it, and __exit__ stops it and prints the time.
Here's what the decorator couldn't do: time the separate parts of one function:
def build_report(rows):
with Timer(" parse "):
time.sleep(0.2)
parsed = [row.split(",") for row in rows]
with Timer(" aggregate"):
time.sleep(0.5) # the slow step
total = sum(int(cells[1]) for cells in parsed)
return total
with open("context_demo/sales.csv", encoding="utf-8") as f:
next(f) # skip the header row
rows = [line.strip() for line in f]
with Timer("build_report"):
print("total units:", build_report(rows))
parse : 0.2s
aggregate: 0.5s
total units: 443
build_report: 0.7s
The output works like a small profiler (a tool that shows where time is spent), and it shows straight away that aggregate is the slow step. Notice that the timers can even be nested: the outer build_report timer includes both inner timers (0.2 + 0.5 = 0.7 seconds).
Analogy: a decorator is like timing a whole race. A context manager is like a stopwatch with a lap button: you can time each lap separately and see exactly which one was slow.
@execution_timer (decorator) |
with Timer(...) (context manager) |
|
|---|---|---|
| Times | a whole function | any block of code |
| Applied | once, at the definition | anywhere, as often as you like |
| Needs the code to be | a function | anything |
| Answers | "which function is slow?" | "which part of this function is slow?" |
You'll use both, because they answer different questions.
A Class-Based Manager Can Be Reused
A class-based manager can be used again and again. You can enter the same Timer object several times, and it keeps self.elapsed afterwards for you to read:
stage = Timer("reusable")
with stage:
time.sleep(0.2)
with stage:
time.sleep(0.5)
print(f"the instance still remembers the last run: {stage.elapsed:.1f}s")
reusable: 0.2s
reusable: 0.5s
the instance still remembers the last run: 0.5s
Part 7: @contextlib.contextmanager, Generators Plus Decorators
Writing a whole class for every setup-and-cleanup pair is a lot of typing. The standard library has a shortcut, and it's built from two ideas you already know:
- Generators: a generator runs up to
yield, pauses, and later carries on from where it stopped. - Decorators: a decorator wraps a function to give it new behaviour.
contextlib.contextmanager is a decorator that turns a generator function into a context manager:
- Everything before the
yielddoes the job of__enter__. - The
withblock runs at theyield, while the generator is paused. - Everything after the
yielddoes the job of__exit__.
Analogy: a sandwich. The top slice of bread is the setup, the filling is your with block, and the bottom slice is the cleanup. yield marks where the filling goes.
@contextmanager
def section(name):
print("[start]") ← top bread: runs when the with block is entered (__enter__)
yield ← the filling: the with block runs HERE, generator paused
print("[end]") ← bottom bread: runs when the with block ends (__exit__)
from contextlib import contextmanager
@contextmanager
def section(name):
print(f"[start] {name}") # __enter__
yield # the with-block runs right here
print(f"[end] {name}") # __exit__
with section("loading data"):
print(" ...reading rows...")
print("back outside the block")
[start] loading data
...reading rows...
[end] loading data
back outside the block
Same output as the Section class from Part 3, in about half the code.
Whatever you yield is what as gives you. It's the generator's version of return self:
@contextmanager
def collected():
items = []
yield items # handed to 'as'
print(f"collected {len(items)} items: {items}")
with collected() as bucket:
bucket.append("north")
bucket.append("south")
collected 2 items: ['north', 'south']
The Bug: Cleanup That Never Runs
There's a trap here that's easy to fall into. Look at this timer, which has no try anywhere:
@contextmanager
def timer(label):
start = time.perf_counter()
yield
print(f"{label}: {time.perf_counter() - start:.1f}s") # after the yield
with timer("happy path"):
time.sleep(0.2)
happy path: 0.2s
That works. Now make the block fail:
try:
with timer("crashing block"):
time.sleep(0.2)
raise RuntimeError("boom")
except RuntimeError as e:
print("caught:", e)
print("...and notice there is no timing line above")
caught: boom
...and notice there is no timing line above
The cleanup never ran. There's no timing line at all.
Here's why. When the with block raises an error, Python sends that error into the generator at the yield, as if the yield line itself had raised it. (A paused generator can be woken up in more than one way: for example, by asking for the next value, or by having an error sent into it.) The error then flies straight out of the generator, so the lines after yield are skipped.
Analogy: it's like a shop where the "remember to lock up" step is written at the end of the closing checklist. If the fire alarm goes off halfway through the checklist, everyone runs out and nobody reaches the last line.
The class version never had this problem, because Python always calls __exit__. To get the same guarantee from a generator, put the yield inside a try/finally yourself:
@contextmanager
def timer(label):
start = time.perf_counter()
try:
yield
finally: # runs on the way out, always
print(f"{label}: {time.perf_counter() - start:.1f}s")
try:
with timer("crashing block"):
time.sleep(0.2)
raise RuntimeError("boom")
except RuntimeError as e:
print("caught:", e)
crashing block: 0.2s
caught: boom
Now the timing line printed even though the block crashed, and the error still reached our except.
If the cleanup has to happen, put the yield inside a try/finally. Make that your default way of writing these.
Handling the Error Inside a Generator Manager
A try/except around the yield is the generator's version of "__exit__ returned True". Catch the error, don't raise it again, and it stops there:
@contextmanager
def ignore_missing_file():
try:
yield
except FileNotFoundError as e:
print(f"ignoring: {e.filename} was already gone")
with ignore_missing_file():
os.remove("context_demo/never_existed.txt")
print("cleanup finished")
ignoring: context_demo/never_existed.txt was already gone
cleanup finished
A @contextmanager Object Only Works Once
One more thing: an object made by a @contextmanager function only works once. Entering it runs the generator up to the yield. A generator can't be restarted, so there's nothing left to run a second time:
cm = section("used once")
with cm:
print(" first use is fine")
try:
with cm:
print(" second use")
except AttributeError as e:
print("second use failed:", e)
[start] used once
first use is fine
[end] used once
second use failed: '_GeneratorContextManager' object has no attribute 'args'
The message is hard to understand. It just means this manager has already used up its generator. The fix is to call the function again each time: write with section("..."): instead of saving it as cm = section("...") and reusing with cm:.
Analogy: a generator-based manager is like a single-use ticket. A class-based manager like Timer is like a season pass.
Part 8: Class or Generator?
Both give you a context manager. Pick based on what the block needs.
| Use a class when | Use @contextmanager when |
|---|---|
The block needs methods on the object (db.run(...)) |
The block just needs setup and teardown |
| You want to reuse one instance repeatedly | A fresh one per with is fine |
You want state readable afterwards (stage.elapsed) |
Nothing survives the block |
| You want to subclass or extend it | It is a one-off helper |
| The teardown logic is long | It fits in a few lines |
Most context managers you write yourself will be short helpers, so you'll mostly use @contextmanager. Most of the ones you use from libraries (files, database connections, locks) are classes, because they come with methods.
Part 9: The contextlib Toolkit
contextlib (a module in Python's standard library) has more in it than the decorator. Three of its tools are useful right now.
suppress: A Shorter try/except/pass
contextlib.suppress is a short way to ignore one kind of error, and now you can see what it really is: a context manager. This:
try:
os.remove(path)
except FileNotFoundError:
pass
becomes:
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove("context_demo/does_not_exist.txt")
print("no crash: the file was already gone, which is fine")
no crash: the file was already gone, which is fine
It's shorter, and unlike a bare except: pass, it names exactly which error you're happy to ignore. You can list more than one: suppress(FileNotFoundError, PermissionError).
Keep the block to one statement. suppress doesn't skip the failing line and carry on. It ends the whole block, just like any other error would:
for name in ["temp_a.txt", "temp_b.txt"]:
with open(f"context_demo/{name}", "w", encoding="utf-8") as f:
f.write("scratch\n")
with suppress(FileNotFoundError):
os.remove("context_demo/temp_a.txt") # exists, so it is removed
os.remove("context_demo/gone.txt") # missing, so the block ends HERE
os.remove("context_demo/temp_b.txt") # never runs
print("temp_a.txt still there?", os.path.exists("context_demo/temp_a.txt"))
print("temp_b.txt still there?", os.path.exists("context_demo/temp_b.txt"))
os.remove("context_demo/temp_b.txt") # clean up properly
temp_a.txt still there? False
temp_b.txt still there? True
temp_b.txt survived, because the line that would have deleted it never ran. It's easy to assume suppress works line by line, but it doesn't.
Analogy: a fire drill doesn't just skip one sentence of a meeting; it ends the whole meeting. Everything after the alarm is cancelled.
So use one statement per with suppress(...), or put the with inside a loop.
closing: For Objects with close() but No with Support
Lots of older libraries give you an object that has a close() method but doesn't support with. closing wraps it so it works with with anyway:
from contextlib import closing
class LegacyCursor:
# An old-style object: it has close(), but no __enter__/__exit__.
def fetch(self):
return [("north", 120), ("south", 95)]
def close(self):
print("[cursor] closed")
with closing(LegacyCursor()) as cursor:
print("rows:", cursor.fetch())
rows: [('north', 120), ('south', 95)]
[cursor] closed
closing(obj) is a context manager whose __exit__ calls obj.close(). That's all it does.
Analogy: a travel adapter. Your old device has the wrong plug for this socket; the adapter makes it fit.
redirect_stdout: Catching What a Function Prints
Sometimes a library prints things you'd rather capture: to save in a log, to hide from the user, or to check in a test. redirect_stdout sends everything print() writes somewhere else while the block runs. Here it goes into io.StringIO(), which acts like a text file that lives in memory:
import io
from contextlib import redirect_stdout
def noisy_model_training():
print("epoch 1/3 loss=0.412")
print("epoch 2/3 loss=0.233")
print("epoch 3/3 loss=0.187")
return 0.187
buffer = io.StringIO() # an in-memory text file
with redirect_stdout(buffer):
final_loss = noisy_model_training() # its prints go into the buffer
print("nothing was printed by the call above")
print("final loss:", final_loss)
print("captured lines:", buffer.getvalue().strip().splitlines())
nothing was printed by the call above
final loss: 0.187
captured lines: ['epoch 1/3 loss=0.412', 'epoch 2/3 loss=0.233', 'epoch 3/3 loss=0.187']
buffer.getvalue() gives back everything written into the buffer as one string. This is handy in tests, when you want to check what a function printed. When the block ends, print() goes back to the screen automatically.
Analogy: plugging a microphone into a recorder instead of the speakers. The person still talks normally, but nobody in the room hears it; it's all saved on the recording instead.
Part 10: Several Managers in One with
You often need two at once, like reading from one file while writing to another. Nesting one with inside another works:
with open("in.csv") as fin:
with open("out.csv", "w") as fout:
...
A single with can also take several managers separated by commas, and it closes them in reverse order. The \ at the end of the first line tells Python the statement carries on onto the next line:
with open("context_demo/sales.csv", encoding="utf-8") as fin, \
open("context_demo/big_regions.csv", "w", encoding="utf-8") as fout:
fout.write(next(fin)) # copy the header across
for line in fin:
region, units = line.strip().split(",")
if int(units) >= 100:
fout.write(line)
print(Path("context_demo/big_regions.csv").read_text(encoding="utf-8"))
region,units
north,120
east,140
This copied the header and only the regions with 100 or more units into a new file.
Why reverse order? Think of putting on socks, then shoes. When you take them off, you remove the shoes first, then the socks. The last thing set up is the first thing cleaned up, because later setups may depend on earlier ones.
with A as a, B as b: enter A → enter B → run body → exit B → exit A
(first in) (last in, first out)
From Python 3.10, you can wrap the list in parentheses instead, which is easier to read once there are three or more:
with (
open("sales.csv") as fin,
open("clean.csv", "w") as fout,
Timer("copy"),
):
...
The last one has no as. You only write as when you need the value.
Part 11: Real-World Examples
Netflix: A Transaction That Rolls Back
This is what __exit__'s three arguments are really for. When a streaming service charges your card, either everything should happen, or nothing should. Either the block finishes and we keep the changes (that's called a commit), or it raises an error and we put everything back the way it was (a rollback).
Analogy: a save point in a video game. Before a difficult level, the game saves your progress. If you fail the level, it reloads the save as if the failed attempt never happened.
class InsufficientFunds(Exception):
pass
class Transaction:
# Snapshot on the way in; restore the snapshot if the block fails.
def __init__(self, account):
self.account = account
def __enter__(self):
self.snapshot = dict(self.account)
return self.account
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is None:
print(" committed:", self.account)
else:
self.account.clear()
self.account.update(self.snapshot)
print(f" rolled back after {exc_type.__name__}:", self.account)
# no return: the exception is not ours to swallow
def charge(account, amount):
with Transaction(account) as acct:
acct["balance"] -= amount
acct["charges"] += 1
if acct["balance"] < 0:
raise InsufficientFunds(f"balance would be {acct['balance']}")
wallet = {"balance": 100, "charges": 0}
print("charging 30:")
charge(wallet, 30)
print("\ncharging 500:")
try:
charge(wallet, 500)
except InsufficientFunds as e:
print(" refused:", e)
print("\nfinal state:", wallet)
charging 30:
committed: {'balance': 70, 'charges': 1}
charging 500:
rolled back after InsufficientFunds: {'balance': 70, 'charges': 1}
refused: balance would be -430
final state: {'balance': 70, 'charges': 1}
How Transaction works:
__enter__takes a snapshot (a copy) of the account before anything changes, and hands the real account to the block.- If the block finishes cleanly,
__exit__prints "committed" and keeps the changes. - If the block raises an error,
__exit__restores the account from the snapshot, and (because it returns nothing) lets the error continue to the caller.
The failed charge left no trace: neither the balance nor the charge counter changed.
Why clear() and update()? __exit__ restores the dictionary with clear() and update() instead of writing self.account = self.snapshot. That's because the caller's wallet itself has to change. The assignment would only point self.account at a different dictionary and leave wallet as it was.
Here's the same charge without a Transaction. The balance and the counter both change before the check fails, and nothing puts them back:
def charge_unprotected(account, amount):
account["balance"] -= amount
account["charges"] += 1
if account["balance"] < 0:
raise InsufficientFunds(f"balance would be {account['balance']}")
wallet = {"balance": 70, "charges": 1}
try:
charge_unprotected(wallet, 500)
except InsufficientFunds as e:
print("refused:", e)
print("wallet now:", wallet)
refused: balance would be -430
wallet now: {'balance': -430, 'charges': 2}
The charge was refused, but the wallet now sits at -430 with an extra charge counted. That's exactly the mess the context manager prevents.
Any Script: A Temporary Working Directory
os.chdir() changes the working directory (the folder that relative paths start from) for the whole program. Forget to change it back and every relative path used afterwards breaks, in ways that are painful to track down.
Analogy: borrowing a colleague's desk for an hour. You'd better move back to your own desk afterwards, or you'll spend the afternoon looking for your stapler in the wrong drawer.
@contextmanager
def working_directory(path):
previous = os.getcwd()
os.chdir(path)
try:
yield
finally:
os.chdir(previous) # even if the block explodes
start = os.getcwd()
with working_directory("context_demo"):
print("inside:", os.path.basename(os.getcwd()))
print("sales.csv visible without a path?", os.path.exists("sales.csv"))
print("back where we started?", os.getcwd() == start)
inside: context_demo
sales.csv visible without a path? True
back where we started? True
Inside the block, sales.csv could be found without writing context_demo/ in front of it. After the block, we were back where we started. And the finally makes sure the directory is put back even when the block fails:
try:
with working_directory("context_demo"):
raise RuntimeError("something went wrong in here")
except RuntimeError as e:
print("caught:", e)
print("still back where we started?", os.getcwd() == start)
caught: something went wrong in here
still back where we started? True
Any Data Pipeline: Writing a File Safely
Writing a file directly has a hidden risk. Opening a file with "w" empties it straight away. So if the program crashes halfway through writing, the old contents are already gone and the new ones are only half written. Here's that happening:
plain = "context_demo/plain_report.txt"
with open(plain, "w", encoding="utf-8") as f:
f.write("Regional report\nnorth: 120\n") # a good file to start with
try:
with open(plain, "w", encoding="utf-8") as f: # "w" empties the file right here
f.write("Regional report\n")
f.write("north: " + str(int("nrth"))) # crashes mid-write
except ValueError as e:
print("write failed:", e)
print("what's left:", repr(Path(plain).read_text(encoding="utf-8")))
write failed: invalid literal for int() with base 10: 'nrth'
what's left: 'Regional report\n'
The north: 120 line from the good version is gone, and only half of the new report made it in.
The fix is to write to a temporary file first, and only swap it into place once the writing has finished. This is called an atomic write: it either happens completely or not at all. os.replace() does the swap in one step, replacing the old file.
Analogy: a stage crew changes the scenery behind a closed curtain, then opens the curtain on the finished set. The audience never sees a half-built stage. If something goes wrong backstage, they just keep looking at the old scene.
atomic_write("report.txt")
│
▼
write everything into report.txt.tmp (the real report.txt is untouched)
│
├── block succeeded → close the temp file, os.replace(tmp → report.txt) ✔ new report in place
│
└── block crashed → close and DELETE the temp file, re-raise the error ✔ old report untouched
@contextmanager
def atomic_write(path, encoding="utf-8"):
temp_path = path + ".tmp"
f = open(temp_path, "w", encoding=encoding)
try:
yield f # the block writes into the temp file
except Exception:
f.close()
os.remove(temp_path) # failed, so throw the partial file away
raise # and let the caller know
else:
f.close()
os.replace(temp_path, path) # succeeded, so swap it into place
Here, atomic_write uses try/except/else instead of try/finally, because success and failure need different cleanup:
except Exception:runs only if the block failed. It throws away the half-written temp file, then uses a bareraiseto send the same error on to the caller.else:runs only if the block succeeded. It swaps the finished temp file into place.
report = "context_demo/report.txt"
with atomic_write(report) as f:
f.write("Regional report\n")
f.write("north: 120\n")
print("first write:")
print(Path(report).read_text(encoding="utf-8"))
first write:
Regional report
north: 120
Now let's crash halfway through a second write:
try:
with atomic_write(report) as f:
f.write("Regional report\n")
f.write("north: " + str(int("nrth"))) # crashes mid-write
except ValueError as e:
print("write failed:", e)
print("\nthe good file is untouched:")
print(Path(report).read_text(encoding="utf-8"))
print("no leftover .tmp file:", not os.path.exists(report + ".tmp"))
write failed: invalid literal for int() with base 10: 'nrth'
the good file is untouched:
Regional report
north: 120
no leftover .tmp file: True
Two things to notice. The old report survived a crash that happened after writing had already started. And no leftover .tmp file was left lying around.
(One small detail: os.replace() is only guaranteed to swap in a single step when the temporary file and the real file are on the same drive. Keeping the .tmp file right next to the real one, as atomic_write does, takes care of that.)
Part 12: Going Further, ExitStack
Feel free to skip this part on a first read. You'll know when you need it.
A comma-separated with handles a fixed number of managers. ExitStack handles a number you only find out while the program runs (for example, "open every file in this list", where the list could have 3 files or 300). It closes every one of them, even if opening, say, the fourth one fails.
Analogy: a coat-check counter that accepts any number of coats as people arrive, and guarantees to hand every single one back at closing time, in reverse order.
from contextlib import ExitStack
names = ["sales.csv", "report.txt", "big_regions.csv"]
with ExitStack() as stack:
files = [stack.enter_context(open(f"context_demo/{n}", encoding="utf-8"))
for n in names]
print("opened", len(files), "files at once")
for name, f in zip(names, files):
print(f" {name:18} first line: {f.readline().strip()}")
print("all closed:", all(f.closed for f in files))
opened 3 files at once
sales.csv first line: region,units
report.txt first line: Regional report
big_regions.csv first line: region,units
all closed: True
stack.enter_context(cm) enters a manager straight away and remembers to exit it later. When the with block ends, everything it remembered is exited in reverse order.
Part 13: Common Mistakes
| Mistake | Symptom | Fix |
|---|---|---|
__enter__ with no return self |
'NoneType' object has no attribute ... |
Return something |
Cleanup after yield, no try/finally |
Teardown silently skipped when the block raises | Wrap the yield |
__exit__ returning a truthy non-boolean |
Exceptions vanish for no visible reason | Return True, False, or nothing |
Reusing a @contextmanager object |
Cryptic AttributeError on the second with |
Call the function again each time |
Several statements inside with suppress(...) |
Later statements silently skipped | One statement per block |
with on a plain value |
object does not support the context manager protocol |
Pass the manager, not the path or number |
One more: don't open a file without with. The plain open()/close() style is useful for understanding what a file object is, but from here on there's no reason to write it that way.
Quick Reference Summary
What with expands to
manager = EXPR
value = manager.__enter__() # 'as value'
try:
body
finally:
manager.__exit__(exc_type, exc_value, traceback)
The class form
class Resource:
def __enter__(self):
# acquire
return self # what 'as' binds
def __exit__(self, exc_type, exc_value, traceback):
# release: always called
return False # True would swallow the exception
The generator form
from contextlib import contextmanager
@contextmanager
def resource():
# acquire
try:
yield value # what 'as' binds
finally:
pass # release
__exit__'s arguments
| Block finished | exc_type |
exc_value |
traceback |
|---|---|---|---|
| Cleanly | None |
None |
None |
| By raising | the exception class | the exception object | its traceback |
contextlib essentials
| Tool | Purpose |
|---|---|
@contextmanager |
Generator function to context manager |
suppress(SomeError) |
Ignore one kind of error (one statement per block) |
closing(obj) |
Call obj.close() on the way out |
redirect_stdout(buf) |
Capture what a block prints |
ExitStack() |
A number of managers known only at runtime |
Several at once
with open("in.csv") as fin, open("out.csv", "w") as fout:
...
Key words
| Term | Meaning |
|---|---|
| Context manager | An object with __enter__ and __exit__, usable in with |
| Protocol | A set of methods an object must have to work with a feature |
| Commit / rollback | Keep the changes / undo them |
| Atomic | Happens completely or not at all |
| Truthy / falsy | Values that if treats as true / false |
When to use one: any time you'd otherwise write "and then remember to put it back". Files, connections, locks, settings, directories, transactions, timers.
A context manager is a pair of steps, setup and cleanup, packaged so that the cleanup always happens, even when the code in the middle crashes. with simply calls __enter__ on the way in and __exit__ on the way out, and @contextmanager lets a generator do both with a single yield in the middle. Everything in this guide follows from that: why the file closes after a crash, why a missing return self gives you None, why a stray return 2 can hide an error, and how a few lines can give you rollbacks and safe file writes. Next up, in chapter 15, we tour the standard library modules you'll reach for most: collections, itertools, functools and datetime.