Python Context Managers: Clean Up Even When Things Fail
How with works, how to write your own context manager as a class or with contextlib, and what happens to errors inside it. After this, files, locks and connections get closed even when the code fails.
Course content
Sign in to save your progress.
-
Functions That Wrap Functions
-
Python Closures: Functions That Remember
-
Python Decorators in Real Code: Timing, Retry and Logging
-
-
-
Data That Doesn't Fit in Memory
-
Python Iterators: What a for Loop Actually Does
-
Python Generators and yield: One Piece at a Time
-
Python Context Managers: Clean Up Even When Things Fail
-
-
-
Catch Bugs Before You Run the Code
-
Python Type Hints Beyond the Basics: Optional, Union, Generics
-
Checking Types With mypy in a Real Project
-
-
-
Doing More Than One Thing at a Time
-
Python Concurrency Basics: Threads, Processes or asyncio?
-
Python asyncio: Wait for Many Things Without Waiting Longer
-
-
-
Ship It, Then Make It Fast
-
Packaging a Python Project With pyproject.toml
-
Profiling Python Code: Measure Before You Optimize
-
-
- Final Check: Python Advanced
A context manager is anything you can use with with. Its job is to set something up and guarantee the clean-up runs afterwards, even if the code in the middle fails. Files are the example everyone knows. Locks, database transactions, temporary folders and "change this setting, then put it back" are the same idea.
Skip ahead: If you have written context managers with __enter__/__exit__ and with @contextmanager, and know what returning True from __exit__ does, take the section quiz.
What with actually does
This:
with open("data.txt", encoding="utf-8") as f:
process(f)
is roughly this:
manager = open("data.txt", encoding="utf-8")
f = manager.__enter__()
try:
process(f)
finally:
manager.__exit__(...) # always runs, error or not
The try/finally is the whole point. You could write it by hand every time. People don't, or they forget once, which is why with exists.
Writing one as a class
A timer that reports how long a block took, including when the block fails:
import time
class Timer:
def __init__(self, label):
self.label = label
def __enter__(self):
self.start = time.perf_counter()
return self # this is what "as t" receives
def __exit__(self, exc_type, exc_value, traceback):
self.elapsed = time.perf_counter() - self.start
status = "failed" if exc_type else "ok"
print(f"{self.label}: {status} after {self.elapsed:.1f}s")
return False # don't hide the error
with Timer("import") as t:
time.sleep(0.3)
try:
with Timer("export"):
time.sleep(0.1)
raise ValueError("disk full")
except ValueError as error:
print("caught:", error)
import: ok after 0.3s
export: failed after 0.1s
caught: disk full
__exit__ gets three arguments describing the error, or three None values if the block finished normally. What it returns decides what happens to the error.
⚠ Warning: If __exit__ returns True, the error is swallowed and the program continues as if nothing happened. Only do that on purpose, for specific error types you expect. A context manager that returns True for everything hides every bug that happens inside its block.
The shorter way: @contextmanager
For most cases, a generator function with contextlib.contextmanager is less code. Everything before yield is the setup, the yielded value is what as receives, and everything after is the clean-up:
import os
from contextlib import contextmanager
@contextmanager
def working_directory(path):
previous = os.getcwd()
os.chdir(path)
try:
yield path
finally:
os.chdir(previous) # runs even if the block fails
os.makedirs("exports", exist_ok=True)
start = os.getcwd()
with working_directory("exports"):
print(os.path.basename(os.getcwd()))
print(os.getcwd() == start)
exports
True
The try/finally around yield is required. Without it, an error inside the with block is raised at the yield, and the clean-up line after it never runs. It is the most common mistake with @contextmanager.
A real-world example: write all or nothing
A file that is either fully written or not touched at all. If anything fails while writing, the original file stays as it was:
import os
from contextlib import contextmanager
@contextmanager
def atomic_write(path, encoding="utf-8"):
tmp = path + ".tmp"
f = open(tmp, "w", encoding=encoding)
try:
yield f
f.close()
os.replace(tmp, path) # only reached if the block succeeded
except BaseException:
f.close()
os.remove(tmp)
raise
with atomic_write("prices.csv") as f:
f.write("sku,price\nKB-01,1200\n")
try:
with atomic_write("prices.csv") as f:
f.write("sku,price\n")
raise RuntimeError("source API went down halfway")
except RuntimeError as error:
print("failed:", error)
with open("prices.csv", encoding="utf-8") as f:
print(f.read(), end="")
print(os.path.exists("prices.csv.tmp"))
failed: source API went down halfway
sku,price
KB-01,1200
False
The second write failed halfway, and prices.csv still has the complete first version. No half-written file, no leftover temp file. Every script that writes the same file would need this logic. As a context manager, it is one with line.
Useful ones that already exist
import os
import tempfile
from contextlib import suppress
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "scratch.txt")
with open(path, "w", encoding="utf-8") as f:
f.write("temporary")
print(os.path.exists(path))
print(os.path.exists(path)) # the folder and its contents are gone
with suppress(FileNotFoundError):
os.remove("does-not-exist.txt") # no error if it isn't there
print("continued")
True
False
continued
Also worth knowing: threading.Lock() works with with and always releases the lock, and database libraries usually offer a transaction as a context manager that commits on success and rolls back on error. Check the library's documentation for the exact behaviour before relying on it.
Common mistakes
- No
try/finallyaroundyieldin a@contextmanagerfunction. The clean-up silently doesn't run on errors. - Using
suppressfor errors you don't understand. It is for one specific, expected error.suppress(Exception)is a bareexcept: passwith better manners. - Using the object after the block. After
with open(...) as f:ends,fstill exists but is closed. Reading from it raisesValueError: I/O operation on closed file.
💡 Tip: Look for try/finally blocks in your code that undo something: close, unlock, restore, delete. If the same pattern appears in more than one place, it wants to be a context manager.
What happens if you skip this step
Clean-up code runs on the happy path and gets skipped on the error path. Locks stay locked, temp files pile up, connections leak until the database refuses new ones. These bugs only show up after an error, under load, often days later, which is why they are hard to trace back.
Your script changes a shared setting at the start and restores it at the end. What state is that setting in after the script crashes in the middle?
About
How with works, how to write your own context manager as a class or with contextlib, and what happens to errors inside it. After this, files, locks and connections get closed even when the code fails.
- Duration
- 5 min
- Last update
- 09/05/2026
Comments (0)
Sign in to commentNo comments yet.