codejump

AcademyPython

Context Managers in Python

What with is really calling, why __exit__ runs even when the block raises, and how @contextmanager turns a generator into one without writing a class.

3 min readUpdated

3 exercises

Waiting at the end of this page.

What with is syntax for

python
with open('data.txt') as f:
    contents = f.read()

is equivalent to:

python
f = open('data.txt').__enter__()
try:
    contents = f.read()
finally:
    open('data.txt').__exit__(...)

— except the object is only constructed once, and __enter__ and __exit__ are called on that same object. __enter__ runs before the block and its return value is what as f binds; __exit__ runs after the block ends, whether it ended normally or by raising. That guarantee — cleanup runs no matter how the block exits — is the entire reason the protocol exists: f.close() in a finally clause does the same thing, but with makes forgetting the finally impossible.

The two methods a context manager needs

python
class Timer:
    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
        return False

with Timer() as t:
    do_work()
print(t.elapsed)

__exit__ receives the exception type, value and traceback if the block raised, or three Nones if it did not. Returning False — or nothing, since None is falsy — lets a raised exception propagate normally after cleanup runs. Returning True swallows it, which is occasionally intentional but easy to do by accident: a bare return with no value at the end of __exit__ is fine, but a stray return True silently deletes every exception the block could raise.

Writing one without a class

python
from contextlib import contextmanager

@contextmanager
def timer():
    start = time.perf_counter()
    try:
        yield
    finally:
        print(f'took {time.perf_counter() - start:.3f}s')

with timer():
    do_work()

@contextmanager turns a generator function into a context manager: everything before yield is __enter__, the yielded value is what as binds, and everything after yield is __exit__. Wrapping the resume in try/finally is what makes the cleanup run even if the block raises — the exception is thrown back into the generator at the yield point, and without the finally the cleanup code below yield would simply never run.

Why open() is the example everyone reaches for

A file handle is a scarce resource with an unambiguous cleanup step, which is exactly the case the protocol is for: acquire something, guarantee its release, regardless of what happens in between. The same shape covers a database transaction that must commit or roll back, a lock that must release, a temporary directory that must be deleted, or a monkey-patched setting that must be restored — anything with a clear "undo" step belongs behind a context manager rather than a manual try/finally repeated at every call site.

Nesting and combining several

python
with open('in.txt') as src, open('out.txt', 'w') as dst:
    dst.write(src.read())

A single with statement can open more than one context manager, entering them left to right and exiting them in reverse order — the same nesting a stack of individual with blocks would produce, without the indentation. contextlib.ExitStack handles the case where the number of context managers is not known until runtime, entering each one dynamically and still guaranteeing every one of them exits, even if a later one in the stack raises during setup.

Now practice it

1 challenge · 2 quizzes

Reading this page is the cheap half. These are the exercises that make you use it.