Context Managers
Some things you open, you must close. A file. A network connection. A lock another thread is waiting on. A database transaction someone needs committed or rolled back. The pattern is always the same: 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: the with statement.
This phase covers 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 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 real 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 time you touch a resource. Forget the try/finally
once and you're back to leaking handles - 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's the
finally you no longer have to write. as f is just the name you give whatever
the context manager hands back.
Every with open(...) you've 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. You don't call __enter__
yourself - with calls it.)
Here's a tiny one that prints on enter and exit, 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. More
on them in the gotcha section below.
Here's the shape of it as a picture:
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
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 it
ended, the generator resumed from the yield and printed 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 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, yield ran your block, and
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 it 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 - 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 should give you. (The exact object you wrap varies by library, but the pattern is the point.)
The thread tying them together: setup that must be matched by 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's 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 __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 saw return True - so it suppressed the error. The
line after the with ran normally. Returning None (or nothing) would have let
that ValueError keep propagating and crash the program - 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 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 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.
withguarantees 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, making your code say what it means
about types - 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."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
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?