Context managers: two methods, one returned truth, and one silent data loss
The protocol is exactly two methods on the type. But the fate of an exception after a successful entry is decided by the truthiness of a single value — the one __exit__ returns. Return something truthy and the exception disappears along with the rest of the block, leaving nothing in the log.
Full technical treatment
TL;DR
A with is a guarantee that leaving the block will be handled. Whatever
happens inside — an ordinary end, a return from the middle, an exception —
the manager gets control on the way out and does what it was written for:
closes the file, releases the lock, returns the connection. The protocol is two
methods: one called on entering the block, one on leaving it.
Hence the main consequence: the exit method decides more than "clean up".
as binds what __enter__ returned, not the object itself — a method with no
return hands back None. And the fate of an exception after a successful
entry is decided by the truthiness of the value __exit__ returned: falsy
and the exception carries on, truthy and it disappears along with the rest of
the block. Hence the silent data loss: suppress around a loop instead of
around a line yields 100 instead of 175, with not a single exception in the
log. And if __enter__ raises, __exit__ is never called at all — the
reference's guarantee only starts after a successful entry.
Beyond that is what separates knowing from having read. Both methods are
looked up on the type, not on the instance: p.__enter__ = ... gives a
TypeError. Swallowing an exception group whole and cutting one branch out of
it are different operations, and suppress only learned the second in 3.12.
The price of entering a block: with on a class is 96 ns against 13 ns for an
empty call, @contextmanager is 431 ns (3.13.7); on 3.14 the class form drops
to 65 ns and the generator form to 417.
- a function can end with an error rather than a
return, and then the lines after it never run; - a program holds things it must not merely take but give back: an open file, a connection, a lock;
- you have seen
with open(…) as f:, even without thinking about what stands behind it.
__enter__,__exit__,contextlib,@contextmanager,ExitStack;- exception groups and
except*,async with, bytecode andLOAD_SPECIAL.
Base: what with was invented for
A program holds things it must not merely acquire but eventually give back: an open file has to be closed, a connection returned, a lock released. The whole difficulty is in the word "eventually". The line that releases the resource sits at the end of the work, and the end is not guaranteed to be reached: an error happens along the way and control leaves past that line. The resource stays held — the file unclosed, the lock never returned.
So the release is written not "after the work" but in a block that runs either way:
handle = open("data.txt")
try:
process(handle)
finally:
handle.close() # runs on success and on failure alikeThat is correct but wordy, and it has to be repeated everywhere the resource is
taken. with is the same construction folded into one line:
with open("data.txt") as handle:
process(handle)Here open knows what to do on the way out and does it — on an ordinary end of
the block, on a return from the middle, on an exception. For a reader, with
means exactly one thing: leaving this block will be handled, however the block
ends.
Hence the protocol. For an object to work with with, it has to do two things:
something on entering the block and something on leaving it. In Python those
two things are called __enter__ and __exit__; as receives what the first
one returned, and the second one is called on the way out.
That is already enough to answer the basic interview question. Everything below is about where that guarantee stops, and about the exit method doing more than cleaning up: it also decides the fate of the exception that was on its way out. It is that second job that loses data.
Mechanism 1: two methods, and both on the type
with looks up __enter__ and __exit__ on the TYPE, not on the instance. The reference says so directly.The reference describes executing a with in seven steps. The first five are
about entry: evaluate the expression, load __enter__, load __exit__, call
__enter__, assign the result to the target after as.
The word "load" is not decoration. Both methods go through implicit special method lookup — that is, they are found on the type, not on the instance. You cannot attach them to an object:
class Plain:
pass
p = Plain()
p.__enter__ = lambda: None
p.__exit__ = lambda *a: None
with p: # TypeError: 'Plain' object does not
pass # support the context manager protocolOn 3.14 the message gains (missed __exit__ method): there __exit__ is
loaded first, which is why its name is the one that reaches the error text.
The second thing visible from step 5: as receives the return value of
__enter__, not the manager. A method that forgets to return hands back
None:
class ReturnsNothing:
def __enter__(self): pass
def __exit__(self, *a): pass
with ReturnsNothing() as v:
print(v) # NoneThe object and the as variable being the same is the manager author's
decision, not a rule of the language. open() returns the file itself from
__enter__, so there they coincide; contextlib.suppress returns itself;
@contextmanager returns whatever follows the yield.
Mechanism 2: the truthiness of one value decides the fate of the exception
PEP 343 writes down the translation of with into an explicit try. Read it in
full: nothing in the rest of this lesson goes beyond what these lines already
show.
mgr = (EXPR)
exit = type(mgr).__exit__
value = type(mgr).__enter__(mgr)
exc = True
try:
try:
VAR = value
BLOCK
except:
exc = False
if not exit(mgr, *sys.exc_info()):
raise
finally:
if exc:
exit(mgr, None, None, None)The line if not exit(...): raise is the entire suppression mechanism. The
reference states the same normatively: "If the return value was true, the
exception is suppressed, and execution continues with the statement following
the with statement".
So __exit__ can end in three different ways. The first two are told apart by
the truthiness of the returned value — that is what gets tested, not any
particular object: any truthy value will do, True, a one, a non-empty string.
The third way is the method raising something itself.
The rule has a boundary, and the PEP's transcription names it: exit is called
after __enter__ has returned control. Before a successful entry there is
nothing to decide — __exit__ is not called at all (its own section below).
what __exit__ returned | what happens to the exception |
|---|---|
None (or anything falsy) | propagates, as if the with were not there |
| truthy | disappears; execution resumes after the with |
| raised something itself | the new exception propagates, the old one becomes its context |
An
__exit__that ends inreturn True"so it stops crashing" silences not the one error it was written for, but all of them.
Mechanism 3: the error that does not crash
As with generators, the interesting error is not the one that brings the program down. Here is code that parses rows, one of them broken:
ROWS = [
{"id": 1, "amount": "100"},
{"id": 2, "amount": "no data"}, # broken
{"id": 3, "amount": "50"},
{"id": 4, "amount": "25"},
]The correct version puts suppress around one statement:
total = 0
for row in ROWS:
with suppress(ValueError):
total += int(row["amount"])The wrong one differs by the position of two lines — suppress ends up around
the loop:
total = 0
with suppress(ValueError):
for row in ROWS:
total += int(row["amount"])Measured (identical on 3.13.7 and 3.14.7):
| version | result |
|---|---|
suppress per statement | 175 |
suppress around the loop | 100 |
| correct total | 175 |
The broken record is genuinely skipped — a separate counter in the measurement
gets exactly one. 75 of 175 lost. Not one exception, not one line in
the log: suppress does not
"skip the error", it exits the block entirely — exactly as written in
PEP 343, where suppression means jumping to the statement after the with.
The same mistake written as your own manager is worse, because the call site
contains no mention of suppress:
class Quiet:
def __enter__(self): return self
def __exit__(self, *a): return True # "so it stops crashing"With it, the same total: 100.
Mechanism 4: swallowing a group and cutting out a branch are different operations
In the previous section "suppress" meant one thing: __exit__ returned true and
the block ended. With an exception group there are two operations, and they
give different results (bench/ctxmgr-async/exceptiongroup_suppress.py).
The first is the old one. __exit__ -> True over an ExceptionGroup swallows
the whole group, every branch of it (here and below the script's output is
abridged to the relevant lines and its labels are translated):
1) __exit__ -> True over an ExceptionGroup
__exit__ saw the nested: ['ValueError', 'TypeError']
execution continued: the group was swallowed whole, both branches
The second arrived in 3.12 and works more finely: suppress cuts out its own
branch and reassembles the rest. On 3.11 the same code suppressed nothing at
all:
3.11.15
2) with suppress(ValueError): raise ExceptionGroup('eg', [ValueError])
out came ExceptionGroup 'eg (1 sub-exception)' nested: ['ValueError']
3) ... ExceptionGroup('mix', [ValueError, TypeError])
out came 'mix (2 sub-exceptions)' nested: ['ValueError', 'TypeError']
the same object we raised: True
3.12.3 and newer
2) RESULT: suppressed, execution continued
3) out came 'mix (1 sub-exception)' nested: ['TypeError']
the same object we raised: False
(Labels translated from the script's output; the values are as printed.)
The line saying that what came out is no longer the object that was raised is the whole mechanism. The documentation describes it verbatim:
If the code within the with block raises a BaseExceptionGroup, suppressed
exceptions are removed from the group. Any exceptions of the group which are not
suppressed are re-raised in a new group which is created using the original
group's derive method.
It works downwards, too: a ValueError sitting inside a nested group is cut
out of it on 3.12, and stays where it was on 3.11.
Here it is worth removing a common misconception about the cause.
BaseExceptionGroup.split, which does the cutting, has existed since 3.11 —
the measurement calls it directly on 3.11 and gets the same result. What changed
is not split but suppress, which started calling it: before 3.12 its
__exit__ fitted on one line — return exctype is not None and issubclass(exctype, self._exceptions) — and a group simply did not match that
condition.
Next to suppress it helps to remember except*. It divides a group the same
way — split returns the pair
—
and the reference describes it like this:(match, rest) where match is subgroup(condition) and rest is the remaining non-matching part
The exception type for matching is interpreted as in the case of except, but in
the case of exception groups we can have partial matches when the type matches
some of the exceptions in the group. This means that multiple except* clauses
can execute, each handling part of the exception group.
The practical consequence for code that goes anywhere near a TaskGroup, or any
other structured concurrency: suppress(ValueError) there does not mean "silence
everything" but "take the ValueError out of the group and let the rest through".
From 3.12 onwards.
Mechanism 5: if __enter__ raised, there will be no cleanup
The reference's guarantee is conditional, and the condition is easy to read past:
"if the __enter__() method returns without an error, then __exit__() will
always be called".
Which is to say there is no guarantee at all before a successful entry:
class FailsOnEnter:
def __enter__(self):
self.acquire() # first half succeeded
raise RuntimeError # second did not
def __exit__(self, *a):
self.release() # WILL NOT BE CALLEDVerified by running it: __enter__ writes to
the log before it raises, and after the failure that entry is all the log holds —
__exit__ added nothing. The practical conclusion is that __enter__ must either acquire everything or nothing — its
own try/except inside, rather than hope placed in __exit__.
The reference separately says that with A(), B(): is equivalent to nested
with statements. Put that together with the rule above and you get: if
B.__enter__ raised, A.__exit__ runs and B.__exit__ does not. Verified by
running it, on classes:
enter A | enter B | exit A
A @contextmanager with try/finally prints a different line — there the
cleanup for B does run. Why, in the next section.
Mechanism 6: @contextmanager — where the exception lives
The generator form is shorter, and it shows neither __exit__ nor its three
arguments. The line after yield reads like ordinary cleanup — and any
exception skips it:
@contextmanager
def naive():
resource = acquire()
yield resource
release(resource) # never reached when an exception occursThe contextlib documentation explains why: "If an unhandled exception occurs in
the block, it is reraised inside the generator at the point where the yield
occurred". The exception does not "happen outside" — it is thrown into the
yield, and from there behaves like any exception inside a function: it
unwinds the frame, skipping the lines after yield.
Verified by running it:
| manager | no exception | with an exception |
|---|---|---|
without try/finally | opened, closed | opened |
with try/finally | opened, closed | opened, closed |
The way to suppress an exception in the generator form follows from the same
property: not return True, but an ordinary except that does not re-raise:
@contextmanager
def catches():
try:
yield
except ValueError:
pass # the exception is suppressedThe documentation warns about this explicitly: "If an exception is trapped merely in order to log it… the generator must reraise that exception".
There is also one place where the generator form behaves better than a
class. If the code before yield is wrapped in try/finally and the generator
fails before reaching yield, that finally still runs: the exception unwinds
the generator frame and passes through it. A class in the same situation gets
nothing called at all — which is the difference behind the "on classes" label on
enter A | enter B | exit A above.
Mechanism 7: ExitStack — nesting that is not in the code
When the number of managers is not known in advance — open every file in a list,
every connection in a configuration — nested with statements cannot be written.
That is what ExitStack is for, and it is obliged to behave exactly as nested
with statements would:
Since registered callbacks are invoked in the reverse order of registration,
this ends up behaving as if multiple nested with statements had been used with
the registered set of callbacks. This even extends to exception handling - if an
inner callback suppresses or replaces an exception, then outer callbacks will be
passed arguments based on that updated state.
The second sentence is not a footnote; it is the reason to read the paragraph. It
can be checked directly (bench/ctxmgr-async/exitstack.py, identical output on
3.11–3.14):
4) inner returned True — the block ended without an exception:
exit inner (sees ValueError)
exit outer (sees None)
outer saw None, because inner had already suppressed it
5) out came: KeyError 'substitute'
exit inner (sees ValueError)
exit outer (sees KeyError)
outer saw a KeyError, not the original ValueError
(Labels translated from the script's output.)
So the outer __exit__ sees not the exception that happened but the one left
over after the inner. Logging "what went wrong" at the outer level therefore lies
whenever something inside substitutes.
The rest of the behaviour is what nested with statements would do, and is
therefore predictable: a third __enter__ that raises closes the first two in
reverse order and never opens the fourth; enter_context(object()) gives a
TypeError immediately rather than later. pop_all stands apart: it moves the
registered cleanup into another object, and then leaving the with closes
nothing — whoever received the cleanup closes it. That is the supported way to
hand out an already-open resource without closing it on the way out of the
function that opened it.
Mechanism 8: async with — the same protocol under different names
The asynchronous variant adds not a single new rule. The data model describes both methods with one formulation:
Semantically similar to __enter__(), the only difference being that it must
return an awaitable.
Everything else follows: as gets the returned value, a true from __aexit__
suppresses the exception, the order is the same. Checked by running it
(bench/ctxmgr-async/async_protocol.py), with identical output on 3.11–3.13.
Two things matter in practice that a synchronous with cannot have.
First: between entry and exit, the event loop hands control to other tasks. Not "may" — observable:
4) interleaving with a background task: ['bg-0', 'body started', 'bg-1', 'body finished', 'bg-2']
A lock taken on entry to such a manager is held across an await — and
everything the async lesson says about locks begins here.
Second: the two protocols are not interchangeable. A synchronous manager does
not work under async with, nor the reverse:
3a) async with over a synchronous manager -> TypeError: 'SyncOnly' object does not support the asynchronous context manager protocol
3b) with over an asynchronous manager -> TypeError: 'AsyncOnly' object does not support the context manager protocol
On 3.14 those same two errors began pointing the way out — a direct consequence of that version looking the two methods up separately:
3a) ... (missed __aexit__ method) but it supports the context manager protocol. Did you mean to use 'with'?
3b) ... (missed __exit__ method) but it supports the asynchronous context manager protocol. Did you mean to use 'async with'?
For the mixed case there is AsyncExitStack: it holds both synchronous and
asynchronous managers in one stack and closes them in reverse order of
registration, without separating them by kind. It has no close, only aclose,
and that is written in the documentation rather than inferred from behaviour.
Deeper: one object, one entry
The documentation calls the result of @contextmanager single-use and shows an
example ending like this:
RuntimeError: generator didn't yield
All four versions produce something else. The documentation's own example, run verbatim on 3.11.15, 3.12.3, 3.13.7 and 3.14.7, gives:
AttributeError: '_GeneratorContextManager' object has no attribute 'args'
The reason is visible in the source. Lib/contextlib.py at tag v3.13.7, lines
118–125:
def __enter__(self):
# do not keep args and kwds alive unnecessarily
# they are only needed for recreation, which is not possible anymore
del self.args, self.kwds, self.func
try:
return next(self.gen)
except StopIteration:
raise RuntimeError("generator didn't yield") from NoneThe attribute deletion comes before next(self.gen), so a second entry
fails before the generator is ever touched. The documented RuntimeError lives
in the except StopIteration branch — a second entry never reaches it.
None of this changes the practical conclusion: do not re-enter the object, call
the function again — with careful(): every time. But it is worth knowing: an
AttributeError: ... has no attribute 'args' in unfamiliar code is not a
mystery, it is a re-entry into a spent manager.
Deeper: what changed in 3.14
The times of the two versions cannot be put side by side: the builds of 3.13.7
and 3.14.7 differ by more than the language version — 3.14 has
--with-tail-call-interp enabled, the very flag What's
New in 3.14 credits with "a geometric mean of 3-5% faster".
So in the table below, do not read the columns against each other. Read them
against the first row — the one that involves no with at all: whatever it
moves by is how far the build moved.
| form | 3.13.7 | 3.14.7 |
|---|---|---|
empty call, no with | 12.5 ns | 12.7 ns |
class with __enter__/__exit__ | 95.5 ns | 65.2 ns |
@contextmanager | 430.7 ns | 417.2 ns |
The control did not move at all — 12.5 against 12.7 ns, a difference inside the
spread, so there is no general build speed-up in this pair. The generator form
also goes through with, so it cannot serve as a second control: its 3.1%
mixes the build's contribution with that of the bytecode change. The class form
dropped 31.7%. There is nothing to credit those thirty-two per cent to but
the change itself: the control, measured by the same process in the same
rounds, stayed put. The cause is visible in the bytecode:
on 3.13, entering a with is a single BEFORE_WITH instruction; on 3.14 that
instruction is gone entirely, replaced by ordinary loads and a call:
3.13: CALL 0 / BEFORE_WITH / STORE_NAME x
3.14: CALL 0 / COPY 1 / LOAD_SPECIAL __exit__ / SWAP 2 / SWAP 3
/ LOAD_SPECIAL __enter__ / CALL 0 / STORE_NAME x
More instructions, less time. The motive for the change is recorded in CPython gh-120507, and it is not about speed: "they are bulky and won't optimize well in tier 2. Instead, we should lower them to attribute lookups and calls which can then be optimized". The speed-up is a consequence rather than a goal, and no number is promised either in the issue or in the documentation.
The reordering has a visible side effect. On 3.14 __exit__ is loaded first,
and the error messages say so:
| object | 3.11 – 3.13 | 3.14 |
|---|---|---|
has __enter__, no __exit__ | (missed __exit__ method) | (missed __exit__ method) |
has __exit__, no __enter__ | no detail | (missed __enter__ method) |
| has neither | no detail | (missed __exit__ method) |
That last row is not a typo: when neither method exists, 3.14 names __exit__,
because it never gets as far as __enter__.
Deeper: version history
| Version | Change | What it means for your code |
|---|---|---|
| 2.5 | PEP 343 introduces with and the __enter__/__exit__ pair. The translation into try/finally recorded in the PEP has not changed in meaning since — only its representation in bytecode has. | |
| 3.11 | The BEFORE_WITH instruction appears (dis documentation: “Added in version 3.11”). Entering a with is one instruction. | |
| 3.12 | suppress learns to take exception groups apart: Changed in version 3.12: suppress now supports suppressing exceptions raised as part of a BaseExceptionGroup. The same release rewrote @contextmanager: the three-argument form throw(typ, value, tb) was deprecated, so contextlib moved to throw(value). The measurement sees this from outside: the number of arguments contextlib passes to gen.throw is 3 on 3.11 and 1 from 3.12 on. | |
| 3.14 | BEFORE_WITH disappears from the dis page and LOAD_SPECIAL appears (“Added in version 3.14”). Measured: the class form goes 95.5 → 65.2 ns. Side effect — a TypeError for an object with neither method now names __exit__. |
What measured this
The observations in this article open from here, together with the record of the run:
bench/context-managers/bytecode.pybench/context-managers/cost.pybench/context-managers/generator_cm.pybench/context-managers/protocol.pybench/context-managers/silent.pybench/context-managers/suppress.pybench/context-managers/traps.pybench/ctxmgr-async/exceptiongroup_suppress.pybench/ctxmgr-async/exitstack.pybench/ctxmgr-async/async_protocol.pybench/ctxmgr-async/what_changed_312.py
The last four measure no time at all: they print behaviour on four interpreters — 3.11.15, 3.12.3, 3.13.7 and 3.14.7 — and the differences between them.
How to answer in an interview
The short answer: a with is a guarantee that leaving the block will be
handled, however the block ends. The protocol is two methods on the type:
__enter__ is called on the way in, __exit__ on the way out, and as binds
what __enter__ returned rather than the object itself.
That is enough to answer correctly. Beyond it is what you add if the interviewer digs.
If the interviewer digs deeper
First, name the boundary of the guarantee: __exit__ is called only if
__enter__ returned control without an error. If __enter__ raised, there is
no cleanup at all, and whatever it managed to acquire has to be released inside
it.
Second, say that after a successful entry __exit__ decides more than "clean
up": the fate of the exception in flight is settled by the truthiness of
what it returned. So return True in __exit__ does not mean "we cleaned up
successfully" but "there is no exception any more" — and it silences every
exception, not only the expected one. Hence the silent data loss: suppress
around a loop instead of around a line yields 100 instead of 175, with not a
single exception in the log.
Next they ask
__exit__ returned True. What exactly was suppressed?
Precisely the exception that reached it — and suppression resumes at the line
after the with, not inside the block. The whole with is a try written out
explicitly in PEP 343, and every behaviour here is visible in that transcription:
the lesson has no other source.
And if __enter__ raised — who closes what it had already opened?
Nobody. The reference's guarantee carries a condition that is easy to lose while
reading: __exit__ is always called if __enter__ returned without an
error. Before a successful entry there is no guarantee at all, so anything
acquired inside __enter__ before the raise has to be released inside it.
Common misconceptions
with simply guarantees the file gets closed
The guarantee is conditional, and the reference states the condition outright: __exit__ is called only if __enter__ returned without an error. A manager that fails halfway through __enter__ gets no cleanup at all — verified by running it, the log contains only “__enter__ started”. Resources must be acquired so that __enter__ either does everything or does nothing.
the as variable is the object written after with
It is the return value of __enter__. That they often coincide is the manager author's decision, not a rule: open() returns the file, @contextmanager returns whatever follows the yield, and a method with no return hands back None. One line settles it: with obj as v: print(obj is v) prints False for a manager that returns something of its own.
return True in __exit__ means “we cleaned up successfully”
It means “the exception has been swallowed”. PEP 343 writes the translation literally: if not exit(mgr, *sys.exc_info()): raise — a truthy value cancels the raise. Measured: the same row-parsing example returns 100 instead of 175, with nothing in the log. A method with nothing to say should return nothing.
suppress is shorthand for a try/except around a statement
It exits the block entirely. In the measurement a with suppress(ZeroDivisionError): wrapping two statements executed only the first: suppression means jumping to the statement after the with, not to the next line inside it. That is where the loss of 75 out of 175 in the loop example comes from.
@contextmanager with a yield in the middle is equivalent to try/finally
Only while nothing raises. The contextlib documentation: an exception “is reraised inside the generator at the point where the yield occurred” — so the lines after yield are skipped exactly as the rest of any function would be. Measured: without try/finally, an exception leaves only “opened” in the log.
an object returned by @contextmanager can be reused
It cannot, and the error is not the documented one. The documentation shows RuntimeError: generator didn't yield, while 3.11, 3.12, 3.13 and 3.14 all give AttributeError: '_GeneratorContextManager' object has no attribute 'args' — because __enter__ begins with del self.args, self.kwds, self.func (Lib/contextlib.py, v3.13.7, lines 118–125). Call the function again rather than re-entering the object.
with A(), B() opens both managers independently
It is nesting: the reference says the form is “semantically equivalent to” nested with statements. A measurement with a failing B.__enter__ on classes gives “enter A | enter B | exit A” — B gets no cleanup, A does. Closing works the same way: B first, then A.
with costs almost nothing
On 3.13.7 an empty call is 12.5 ns, the same code inside a with on a class is 95.5 ns, and through @contextmanager it is 430.7 ns. In a handler that hits a database this is noise; in a loop of a million iterations the generator form adds more than four tenths of a second. On 3.14 the class form drops to 65.2 ns and the generator form to 417.2.
Practice
Two exercises. Answer first, then check against the real output: in both, the right answer comes from a recorded run rather than from an editor.
Practice · predict the output
from contextlib import suppress
ROWS = [
{"id": 1, "amount": "100"},
{"id": 2, "amount": "no data"},
{"id": 3, "amount": "50"},
{"id": 4, "amount": "25"},
]
total = 0
added = []
with suppress(ValueError):
for row in ROWS:
total += int(row["amount"])
added.append(row["id"])
print(total)
print(added)Practice · estimate
Knowledge check
__enter__ acquired a lock and then raised. What does with call?
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
- A
withis a guarantee that leaving the block will be handled. Whatever happens inside — an ordinary end, areturnfrom the middle, an exception — the manager gets control on the way out and does what it was written for: closes the file, releases the lock, returns the connection. The protocol is two methods: one called on entering the block, one on leaving it. - Hence the main consequence: the exit method decides more than "clean up".
asbinds what__enter__returned, not the object itself — a method with noreturnhands backNone. And the fate of an exception after a successful entry is decided by the truthiness of the value__exit__returned: falsy and the exception carries on, truthy and it disappears along with the rest of the block. Hence the silent data loss:suppressaround a loop instead of around a line yields 100 instead of 175, with not a single exception in the log. And if__enter__raises,__exit__is never called at all — the reference's guarantee only starts after a successful entry. - Beyond that is what separates knowing from having read. Both methods are looked up on the type, not on the instance:
p.__enter__ = ...gives aTypeError. Swallowing an exception group whole and cutting one branch out of it are different operations, andsuppressonly learned the second in 3.12. The price of entering a block:withon a class is 96 ns against 13 ns for an empty call,@contextmanageris 431 ns (3.13.7); on 3.14 the class form drops to 65 ns and the generator form to 417.
In fact
- The guarantee is conditional, and the reference states the condition outright:
__exit__is called only if__enter__returned without an error. A manager that fails halfway through__enter__gets no cleanup at all — verified by running it, the log contains only “__enter__started”. Resources must be acquired so that__enter__either does everything or does nothing. - It is the return value of
__enter__. That they often coincide is the manager author's decision, not a rule:open()returns the file,@contextmanagerreturns whatever follows theyield, and a method with noreturnhands backNone. One line settles it:with obj as v: print(obj is v)printsFalsefor a manager that returns something of its own. - It means “the exception has been swallowed”. PEP 343 writes the translation literally:
if not exit(mgr, *sys.exc_info()): raise— a truthy value cancels theraise. Measured: the same row-parsing example returns 100 instead of 175, with nothing in the log. A method with nothing to say should return nothing. - It exits the block entirely. In the measurement a
with suppress(ZeroDivisionError):wrapping two statements executed only the first: suppression means jumping to the statement after thewith, not to the next line inside it. That is where the loss of 75 out of 175 in the loop example comes from. - Only while nothing raises. The contextlib documentation: an exception “is reraised inside the generator at the point where the yield occurred” — so the lines after
yieldare skipped exactly as the rest of any function would be. Measured: withouttry/finally, an exception leaves only “opened” in the log. - It cannot, and the error is not the documented one. The documentation shows
RuntimeError: generator didn't yield, while 3.11, 3.12, 3.13 and 3.14 all giveAttributeError: '_GeneratorContextManager' object has no attribute 'args'— because__enter__begins withdel self.args, self.kwds, self.func(Lib/contextlib.py, v3.13.7, lines 118–125). Call the function again rather than re-entering the object. - It is nesting: the reference says the form is “semantically equivalent to” nested
withstatements. A measurement with a failingB.__enter__on classes gives “enter A | enter B | exit A” —Bgets no cleanup,Adoes. Closing works the same way:Bfirst, thenA. - On 3.13.7 an empty call is 12.5 ns, the same code inside a
withon a class is 95.5 ns, and through@contextmanagerit is 430.7 ns. In a handler that hits a database this is noise; in a loop of a million iterations the generator form adds more than four tenths of a second. On 3.14 the class form drops to 65.2 ns and the generator form to 417.2.
By version
- 2.5
- PEP 343 introduces
withand the__enter__/__exit__pair. The translation intotry/finallyrecorded in the PEP has not changed in meaning since — only its representation in bytecode has.< - 3.11
- The
BEFORE_WITHinstruction appears (dis documentation: “Added in version 3.11”). Entering awithis one instruction.< - 3.12
suppresslearns to take exception groups apart: Changed in version 3.12: suppress now supports suppressing exceptions raised as part of a BaseExceptionGroup. The same release rewrote@contextmanager: the three-argument formthrow(typ, value, tb)was deprecated, socontextlibmoved tothrow(value). The measurement sees this from outside: the number of argumentscontextlibpasses togen.throwis 3 on 3.11 and 1 from 3.12 on.<- 3.14
BEFORE_WITHdisappears from the dis page andLOAD_SPECIALappears (“Added in version 3.14”). Measured: the class form goes 95.5 → 65.2 ns. Side effect — aTypeErrorfor an object with neither method now names__exit__.<
What is covered
- Base: what `with` was invented for
- Mechanism 1: two methods, and both on the type
- Mechanism 2: the truthiness of one value decides the fate of the exception
- Mechanism 3: the error that does not crash
- Mechanism 4: swallowing a group and cutting out a branch are different operations
- Mechanism 5: if `__enter__` raised, there will be no cleanup
- Mechanism 6: `@contextmanager` — where the exception lives
- Mechanism 7: `ExitStack` — nesting that is not in the code
- Mechanism 8: `async with` — the same protocol under different names
- Deeper: one object, one entry
- Deeper: what changed in 3.14
- Deeper: version history
- What measured this
- How to answer in an interview
- Next they ask
- Common misconceptions
- Practice
- Knowledge check
Sources & further reading
9 SOURCES
- PEP 343 — The "with" StatementPEP. Guido van Rossum and Alyssa Coghlan, 13 May 2005, status Final, Python 2.5. The source of the literal translation of with into try/finally that the whole lesson rests on: `exit = type(mgr).__exit__` is taken BEFORE `__enter__` is called, and a true result from `exit(...)` means the exception is “swallowed”.https://peps.python.org/pep-0343/
- The language reference — the with statementOfficial documentation. Seven numbered steps of execution. Two rules the lesson verifies by running code: “The with statement guarantees that if the __enter__() method returns without an error, then __exit__() will always be called” and “If the return value was true, the exception is suppressed”. Also the rule that several managers on one line are equivalent to nested with statements.https://docs.python.org/3.14/reference/compound_stmts.html#the-with-statement
- contextlib — utilities for withOfficial documentation. “If an unhandled exception occurs in the block, it is reraised inside the generator at the point where the yield occurred” — the sentence that explains why a generator-based manager needs try/finally. Also single use (“Context managers created using contextmanager() are also single use context managers”) — the sentence the re-entry section rests on.https://docs.python.org/3.14/library/contextlib.html
- dis — BEFORE_WITH (3.13 documentation)Official documentation. “This opcode performs several operations before a with block starts… Added in version 3.11.” The instruction is gone from the 3.14 page; that it is gone from the bytecode too is what
bench/context-managers/bytecode.pyshows with the disassembler.https://docs.python.org/3.13/library/dis.html - dis — LOAD_SPECIAL (3.14 documentation)Official documentation. “Performs special method lookup on STACK[-1]… Added in version 3.14.” The instruction BEFORE_WITH was lowered into. The documentation makes no claim about speed at all — the measured speed-up in this lesson is reported as an observation, not as a promise.https://docs.python.org/3.14/library/dis.html
- CPython gh-120507 — Lower BEFORE_WITH and BEFORE_ASYNC_WITH to attribute lookups and callsSource. Mark Shannon, 14 June 2024. The stated motive is not speed but optimisability: “they are bulky and won't optimize well in tier 2. Instead, we should lower them to attribute lookups and calls which can then be optimized”. The issue contains no numbers.https://github.com/python/cpython/issues/120507
- Lib/contextlib.py — _GeneratorContextManager.__enter__CPython source code. Lines 118–125 at tag v3.13.7. The method's very first line is `del self.args, self.kwds, self.func`, which is why re-entering the same object raises AttributeError rather than the documented RuntimeError. Checked on 3.11, 3.12, 3.13 and 3.14: the method is identical in all four.https://github.com/python/cpython/blob/v3.13.7/Lib/contextlib.py
- The data model — __aenter__ and __aexit__Official documentation. The formulation from which it follows that the asynchronous protocol adds no new rule at all: "Semantically similar to `__enter__()`, the only difference being that it must return an *awaitable*", and the same sentence for `__aexit__`. The translation of `async with` into `try/finally` sits in the language reference and repeats the synchronous one word for word, with an `await` added before each call.https://docs.python.org/3.14/reference/datamodel.html#object.__aenter__
- What's New in Python 3.12 — DeprecatedOfficial documentation. The reason `@contextmanager` was rewritten in 3.12: "The 3-arg signatures (type, value, traceback) of `coroutine throw()`, `generator throw()` and `async generator throw()` are deprecated and may be removed in a future version of Python." The word `contextlib` does not appear in What's New in 3.12 at all — the `suppress` change is recorded only in the contextlib documentation, as a versionchanged note. CPython tag 3.13.7.https://docs.python.org/3.12/whatsnew/3.12.html#deprecated