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 by as.
  • __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 finally for cleanup that always runs
  • Raise exceptions with raise, including the from chaining form
  • Define custom exception classes for domain-specific errors
  • Use with blocks 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.

Toggle theme (T)