Context Managers
Some things you open, you must close. A file. A network connection. A lock that another thread is waiting on. A database transaction someone needs committed or rolled back. The pattern is always the same: do some setup, do your work, then - no matter what happens in the middle - clean up.
That last part is where people get burned. The work in the middle can raise an exception, and if it does, the cleanup line after it never runs. The file stays open. The lock stays held. The connection leaks. You won't notice on a good day; you'll notice at 2am when the server has run out of file handles and nobody can log in.
Python has one tool that makes "always clean up" automatic, and it's the with
statement. This phase is about what with actually guarantees, and how to build
your own.
The problem with solves
The naive version. You open something, use it, close it:
=
=
This looks fine. It is fine - until f.read() raises (the disk hiccups, the file
is malformed, your parsing blows up). Then f.close() never runs, because the
exception jumps straight out of the function. The file handle leaks.
The careful version. The honest fix is try/finally - the finally block
runs whether or not an exception happened:
=
=
This is correct. It's also four lines of ceremony around one line of real work,
and you have to write it every single time you touch a resource. Forget the
try/finally once and you're back to leaking handles. Writing it everywhere is
noisy, and noisy code is code people skip.
What it actually is. A context manager is an object that knows how to set
itself up and, crucially, how to tear itself down - and the with statement wires
that teardown to run automatically, exception or not. It's the try/finally
above, packaged so you write it once and reuse it forever.
💡 Key point.
withis a guarantee: the cleanup runs no matter how the block ends - normal finish,return, or exception. That guarantee is the whole reason context managers exist.
with open(...) - the one you already know
You met this back in Phase 7. Now you can see what it's really doing:
=
# f is closed here - automatically
What just happened: with open(...) as f opened the file and handed it to f.
When the block ends - for any reason, including an exception thrown by
f.read() - Python calls the file's cleanup, which closes the handle. That
closing is the finally you no longer have to write. The as f part is just the
name you give whatever the context manager hands back.
So every with open(...) you've ever written was a try/finally in disguise.
The same machinery works for anything else that needs guaranteed cleanup - you
just have to teach an object how to enter and exit.
The protocol: __enter__ and __exit__
What it actually is. Any object with two dunder methods can be used in a
with statement:
__enter__(self)runs at the top of the block. Its return value is whatasbinds to.__exit__(self, ...)runs at the bottom of the block - always - and that's where cleanup lives.
📝 The context manager protocol - an object is a context manager if it has
__enter__ and __exit__. (Recall from Phase 6:
dunder methods are hooks Python calls for you at the right moment. You don't call
__enter__ yourself - with calls it.)
Here's a tiny one you can run. It prints when it enters and exits, so you can watch the protocol fire:
=
# setup
return # what `as` receives
# teardown - always runs
$ python tags.py
<body>
hello
</body>
What just happened: Entering the with block called __enter__, which printed
the opening tag. Your code printed hello. Then - at the end of the block -
Python called __exit__, which printed the closing tag. You wrote the opening and
closing exactly once, and Python guaranteed they bracket whatever happens between.
Those three parameters on __exit__ - exc_type, exc_value, traceback - are
how Python tells your cleanup whether the block ended normally or blew up. We'll
use them in the gotcha section. For now, notice they're there.
Here's the shape of it as a picture:
flowchart LR
enter["__enter__<br/>(setup)"] --> body["your block"]
body --> exit["__exit__<br/>(cleanup)"]
body -. exception .-> exit
One idea: the body runs after setup, and the exit always runs after the body - whether the body finished cleanly or threw (the dotted path).
The easier way: @contextmanager
Writing a whole class with two dunder methods is a lot of structure for something
that's really just "do this, then yield control, then do that." Python's
contextlib module gives you a shortcut: write a generator, mark it with
@contextmanager, and let a single yield split setup from teardown.
📝 Generator - a function that uses yield to pause and hand a value back to
its caller, then resume where it left off. For a context manager, everything
before the yield is setup, the yielded value is what as gets, and everything
after the yield is teardown.
# everything before yield = setup
yield # hand control to the block; `name` goes to `as`
# everything after yield = teardown
$ python tag_cm.py
<body>
hello from body
</body>
What just happened: The generator ran up to yield, printing the open tag and
pausing. The yielded value name became t via as. Your block ran. When the
block ended, the generator resumed from the yield and ran the rest, printing
the close tag. One function, one yield, the same guarantee - much less ceremony
than the class.
For the cleanup to truly always run, the teardown belongs in a finally, so an
exception in the block can't skip it. Here's the robust form - a small Timer
that reports how long a block took, even if it crashes partway:
=
yield # the block runs here
= -
# always prints the result line
=
$ python timer.py
work: start
work: done
499999500000
What just happened: Setup recorded the start time. The yield ran your block.
The finally ran the teardown - so even if sum(...) had raised, you'd still see
the done line. Wrapping the post-yield work in try/finally is the habit
that makes a @contextmanager bulletproof.
💡 Key point. Two ways to build a context manager, same protocol underneath: a class with
__enter__/__exit__when you need to hold state or reuse the object, or a@contextmanagergenerator when it's a simple setup-yield- teardown. Reach for the generator first; it's less to get wrong.
Where this shows up in real code
The with statement is everywhere once you start looking, because so many things
need guaranteed cleanup:
-
Files -
with open(...) as f:closes the handle. The original example, and the most common. -
Locks - a
threading.Lockis a context manager.with lock:acquires it on entry and releases it on exit, so the lock can't stay stuck even if the protected code raises:= # acquire += 1 # released here, even if the line above had thrownWithout
with, you'dlock.acquire()andlock.release()by hand - and a forgotten or skippedrelease()is a classic deadlock, where another thread waits forever for a lock that's never coming back. -
Database transactions - most DB connection libraries make the connection (or a cursor) a context manager that commits the transaction if the block succeeds and rolls it back if an exception escapes. So
with conn:means "all of this, or none of it" - exactly the all-or-nothing behavior a transaction is supposed to give you. (The exact object you wrap varies by library - check its docs - but the pattern is the point.)
The thread that ties them together: setup that must be matched by teardown, and a
teardown you can't afford to skip. That's the precise shape with was built for.
⚠️ The gotcha: __exit__ runs on exception - and can swallow it
The whole point of with is that cleanup runs even when the block raises. That
is a feature - it's why the file still closes when f.read() blows up. But it
has a sharp edge worth understanding.
When an exception propagates out of a with block, Python passes its details into
__exit__ through those three parameters, and then __exit__'s return value
decides what happens to the exception:
- Return a falsy value (or nothing - the default
None) → the exception continues propagating after cleanup. This is what you almost always want. - Return
True→ Python treats the exception as handled and swallows it. Execution continues after thewithblock as if nothing went wrong.
Watch the difference. Here __exit__ returns True, which makes the error vanish:
return
return True # True = "I handled it" = suppress the exception
$ python swallow.py
about to fail
caught ValueError: boom
we got here - the exception was swallowed
What just happened: The raise sent a ValueError out of the block. Python
called __exit__ with exc_type=ValueError and the exception details, ran the
cleanup (the print), and then saw return True - so it suppressed the error.
The line after the with ran normally. If you'd returned None (or just not
returned anything), that ValueError would have kept propagating and crashed the
program, which is the right behavior the vast majority of the time.
The rule of thumb: let exceptions through. Your __exit__ should clean up and
return nothing, so problems still surface. Returning True to swallow an exception
is a rare, deliberate choice - contextlib.suppress exists for exactly the cases
where you mean it (e.g. "delete this file if it exists, shrug if it doesn't").
Silently eating errors you didn't mean to is how bugs hide for weeks.
🪖 War story. A teammate once wrote a context manager for a flaky API client and, copying a snippet, left a
return Truein__exit__. For months, every failed API call inside thatwithblock vanished without a trace - no error, no log, just silently wrong data downstream. The fix was deleting two characters. When in doubt, return nothing from__exit__.
Recap
- Anything you open you must close - files, locks, connections, transactions.
The
withstatement guarantees the close happens, even on exception, so you don't writetry/finallyby hand every time. with open(...) as f:is atry/finallyin disguise: the file closes when the block ends, no matter how it ends.- The protocol is two dunder methods:
__enter__(setup; its return value is whatasbinds) and__exit__(cleanup; always runs). @contextmanagerturns a generator into a context manager - everything beforeyieldis setup, everything after is teardown. Put the teardown in afinallyso it can't be skipped.- ⚠️
__exit__runs even on exception (that's the point), and its return value controls suppression: return falsy/Noneto let the exception through (almost always what you want); returnTrueonly when you deliberately mean to swallow it.
You can now reach for with anytime something needs reliable cleanup, and build
your own when the standard ones don't fit. Next, we make your code say what it
means about types - adding annotations and letting mypy catch whole classes of
bugs before you ever run the program.
Quick check
See if the protocol stuck. Pick the best answer for each, then check yourself.
[
{
"q": "In a `with` block, what guarantees that cleanup runs even if the code inside the block raises an exception?",
"choices": [
"The exception is silently caught and discarded by `with`",
"`__exit__` is always called when the block ends - normal finish, return, or exception",
"Python re-runs the block until it finishes without error",
"Cleanup only runs if you wrap the block in `try`/`finally` yourself"
],
"answer": 1,
"explain": "The `with` statement calls `__exit__` no matter how the block ends. That's the whole guarantee - it's the `try`/`finally` packaged into the protocol, so the file closes or the lock releases even when an exception fires."
},
{
"q": "Using `@contextmanager`, where does the setup code go and where does the teardown go relative to `yield`?",
"choices": [
"Everything goes before `yield`; teardown is a separate function",
"Setup goes after `yield`, teardown before it",
"Setup goes before `yield`, teardown after it (ideally in a `finally`)",
"`yield` must appear twice - once for setup, once for teardown"
],
"answer": 2,
"explain": "A single `yield` splits the generator: everything before it is setup, the yielded value is what `as` receives, and everything after is teardown. Put the teardown in a `finally` so an exception in the block can't skip it."
},
{
"q": "Your `__exit__` returns `True` after an exception propagates into it. What happens?",
"choices": [
"The exception is suppressed and execution continues after the `with` block",
"The exception keeps propagating, but cleanup is skipped",
"Python raises a new error because `True` is invalid",
"Nothing changes - the return value of `__exit__` is ignored"
],
"answer": 0,
"explain": "Returning `True` tells Python the exception was handled, so it swallows it and runs on. Returning falsy/`None` (the usual choice) lets the exception keep propagating after cleanup - silently eating errors is how bugs hide for weeks."
}
]
← Phase 12: Decorators · Guide overview · Phase 14: Type Hints & mypy →
Check your understanding 3 questions
1. In a `with` block, what guarantees that cleanup runs even if the code inside the block raises an exception?
2. Using `@contextmanager`, where does the setup code go and where does the teardown go relative to `yield`?
3. Your `__exit__` returns `True` after an exception propagates into it. What happens?