Many things in Python need to be cleaned up when you’re done — files closed, connections released, locks freed. Forgetting can leak resources or corrupt data. The with statement automates this.
The problem it solves
Without with, the safe way to use a file is:
f = open("data.txt")
try:
data = f.read()
process(data)
finally:
f.close() # always close, even if process() raises
It works but it’s noisy. The same code with with:
with open("data.txt") as f:
data = f.read()
process(data)
# the file is automatically closed here
When the with block ends — for any reason, including an exception — Python automatically calls the cleanup. Three lines shorter, and impossible to forget.
Anything that supports with
with works with any object that is a context manager. Many built-in things are:
# files
with open("data.txt") as f:
...
# locks (threading)
import threading
lock = threading.Lock()
with lock:
...
# temporary files and directories
import tempfile
with tempfile.TemporaryDirectory() as tmp:
...
The general pattern:
with <expression> as <variable>:
# use variable here
# cleanup happens automatically here
The as <variable> part is optional. Sometimes you don’t need the value, you just want the setup-and-cleanup behaviour.
Multiple resources
You can open multiple resources in one with statement:
with open("source.txt") as src, open("dest.txt", "w") as dst:
dst.write(src.read())
Both files are cleaned up when the block ends.
For Python 3.10+, you can also use parentheses for clarity when the line gets long:
with (
open("source.txt") as src,
open("dest.txt", "w") as dst,
):
dst.write(src.read())
How it works under the hood
A context manager is just an object with two methods:
__enter__— called at the start of the block; returns the value bound byas.__exit__— called at the end; receives any exception info, runs cleanup.
We’ll see the class side of this in Section 12. The point is: writing your own context manager is straightforward when you need one.
Writing your own with contextlib
The standard library has a shortcut: the @contextmanager decorator turns a generator function into a context manager.
import time
from collections.abc import Iterator
from contextlib import contextmanager
@contextmanager
def timed(label: str) -> Iterator[None]:
start: float = time.perf_counter()
try:
yield
finally:
elapsed: float = time.perf_counter() - start
print(f"{label}: {elapsed:.3f}s")
with timed("load data"):
data = load_data()
The function yields once. Everything before the yield is the setup (runs on enter). Everything after is the cleanup (runs on exit). The try/finally makes sure the cleanup happens even if the block raises.
with in real ML code
You’ll see with blocks everywhere:
# PyTorch — disable gradient tracking for inference
with torch.no_grad():
predictions = model(batch)
# Pandas / file I/O
with open("results.csv", "w") as f:
writer = csv.writer(f)
writer.writerows(rows)
# matplotlib — set a style for one plot
with plt.style.context("seaborn-v0_8-darkgrid"):
plt.plot(x, y)
In each case, something is set up at the top, something is undone at the bottom, and the user doesn’t have to remember the cleanup.
Summary of Section 9
You can now:
- Read tracebacks and understand what went wrong
- Catch exceptions with
try/except - Handle multiple exception types
- Use
finallyfor cleanup that always runs - Raise exceptions with
raise, including thefromchaining form - Define custom exception classes for domain-specific errors
- Use
withblocks for automatic resource cleanup
Robust programs are mostly about handling failure well. These tools are the foundation.
What’s next
Section 10: File Handling — reading and writing text, CSV, and JSON files with pathlib.