Python

Context managers — what with actually does

The with statement isn't syntax sugar for try/finally by accident — it's a protocol, and writing your own context manager takes about five lines.

Intermediate

3 min read

The problem with exists to solve

f = open("data.txt")
data = f.read()
f.close()

If f.read() raises, f.close() never runs and the file handle leaks — a real bug that only shows up under load, when the process runs out of file descriptors. The fix is try/finally:

f = open("data.txt")
try:
    data = f.read()
finally:
    f.close()

That's correct, but every resource that needs guaranteed cleanup (files, locks, database connections, network sockets) would need the same three-line ceremony repeated everywhere it's used. with is that pattern, generalized into the language:

with open("data.txt") as f:
    data = f.read()
# f.close() has already run here, even if read() raised

The protocol underneath it

with EXPR as VAR: is not special-cased to files. It works on anything implementing two methods:

class ManagedResource:
    def __enter__(self):
        print("acquiring")
        return self          # this becomes `VAR`
 
    def __exit__(self, exc_type, exc_value, traceback):
        print("releasing")
        return False         # False = don't suppress the exception
 
with ManagedResource() as r:
    print("using it")
acquiring
using it
releasing

__enter__ runs on entry and its return value is what as VAR binds to. __exit__ runs on exit no matter how the block ends — normal completion, a return, a break, or an exception — which is exactly the guarantee try/finally gives, just attached to an object instead of hand-written at every call site.

What the three __exit__ arguments are for

If the block raised, __exit__ receives the exception type, value, and traceback instead of None, None, None. This is what lets a context manager catch and suppress specific exceptions by returning True:

class IgnoreZeroDivision:
    def __enter__(self):
        return self
 
    def __exit__(self, exc_type, exc_value, traceback):
        return exc_type is ZeroDivisionError   # True = swallow only this one
 
with IgnoreZeroDivision():
    1 / 0
print("still runs")   # the exception never propagated

Returning anything falsy (the default, None) lets the exception propagate normally after cleanup runs — which is what you want in the overwhelming majority of cases. Suppressing exceptions silently is a sharp edge, not a default behavior to reach for.

contextlib.contextmanager — the same thing, without a class

Writing a whole class for a resource that's just "do setup, yield control, do teardown" is more ceremony than the idea needs. @contextmanager turns a generator function into a context manager:

from contextlib import contextmanager
 
@contextmanager
def managed_resource():
    print("acquiring")
    try:
        yield "the resource"
    finally:
        print("releasing")
 
with managed_resource() as r:
    print("using", r)

Everything before yield is __enter__; the yielded value is what as VAR binds to; everything after yield (inside the finally) is __exit__. The try/finally around yield matters — without it, an exception raised inside the with block would skip the cleanup code entirely, which defeats the entire point.

Where this shows up constantly in real code

threading.Lock, database transactions (connection.begin()), and unittest.mock.patch are all context managers for the same underlying reason: each one acquires something that has to be released exactly once, exactly when the block ends, regardless of how it ends. Recognizing "acquire, use, guaranteed release" as a shape is what makes reaching for with (or writing one) automatic instead of reinventing try/finally every time.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What guarantee does `with open(path) as f:` give that manually calling open() then close() doesn't?

2. What does a class need to implement to work with the `with` statement?

3. What does it mean if a context manager's __exit__ method returns True?

4. In a @contextmanager generator function, why must the try/finally wrap the yield?