Deep Engineering
Intermediate·Published·3.11 · 3.12 · 3.13 · 3.14·40 MIN

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.

Where to start
Before this lesson it is enough to understand
  • 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.
You do not need to know in advance
  • __enter__, __exit__, contextlib, @contextmanager, ExitStack;
  • exception groups and except*, async with, bytecode and LOAD_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:

PYTHON
handle = open("data.txt")
try:
    process(handle)
finally:
    handle.close()      # runs on success and on failure alike

That is correct but wordy, and it has to be repeated everywhere the resource is taken. with is the same construction folded into one line:

PYTHON
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

language contractLanguage guarantee: 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:

PYTHON
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 protocol

On 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:

PYTHON
class ReturnsNothing:
    def __enter__(self): pass
    def __exit__(self, *a): pass
 
with ReturnsNothing() as v:
    print(v)     # None

The 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.

PYTHON
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__ returnedwhat happens to the exception
None (or anything falsy)propagates, as if the with were not there
truthydisappears; execution resumes after the with
raised something itselfthe new exception propagates, the old one becomes its context

An __exit__ that ends in return 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:

PYTHON
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:

PYTHON
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:

PYTHON
total = 0
with suppress(ValueError):
    for row in ROWS:
        total += int(row["amount"])

Measured (identical on 3.13.7 and 3.14.7):

versionresult
suppress per statement175
suppress around the loop100
correct total175

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:

PYTHON
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.

contextlib — suppress

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 (match, rest) where match is subgroup(condition) and rest is the remaining non-matching part — and the reference describes it like this:

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 language reference — the except* clause

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:

PYTHON
class FailsOnEnter:
    def __enter__(self):
        self.acquire()          # first half succeeded
        raise RuntimeError      # second did not
    def __exit__(self, *a):
        self.release()          # WILL NOT BE CALLED

Verified 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:

PYTHON
@contextmanager
def naive():
    resource = acquire()
    yield resource
    release(resource)        # never reached when an exception occurs

The 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:

managerno exceptionwith an exception
without try/finallyopened, closedopened
with try/finallyopened, closedopened, 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:

PYTHON
@contextmanager
def catches():
    try:
        yield
    except ValueError:
        pass            # the exception is suppressed

The 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.

contextlib — ExitStack

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.

The data model — __aenter__

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:

PYTHON
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 None

The 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

implementation detail · CPython 3.14The wording of an error message is not a contract: it changed between versions and may change again.

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.

form3.13.73.14.7
empty call, no with12.5 ns12.7 ns
class with __enter__/__exit__95.5 ns65.2 ns
@contextmanager430.7 ns417.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:

object3.11 – 3.133.14
has __enter__, no __exit__(missed __exit__ method)(missed __exit__ method)
has __exit__, no __enter__no detail(missed __enter__ method)
has neitherno 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

VersionChangeWhat it means for your code
2.5PEP 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.11The BEFORE_WITH instruction appears (dis documentation: “Added in version 3.11”). Entering a with is one instruction.
3.12suppress 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.14BEFORE_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:

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

Next they ask

__exit__ returned True. What exactly was suppressed?

Short answer

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.

Next they ask

And if __enter__ raised — who closes what it had already opened?

Short answer

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

Claim

with simply guarantees the file gets closed

Actually

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.

Claim

the as variable is the object written after with

Actually

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.

Claim

return True in __exit__ means “we cleaned up successfully”

Actually

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.

Claim

suppress is shorthand for a try/except around a statement

Actually

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.

Claim

@contextmanager with a yield in the middle is equivalent to try/finally

Actually

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.

Claim

an object returned by @contextmanager can be reused

Actually

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.

Claim

with A(), B() opens both managers independently

Actually

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.

Claim

with costs almost nothing

Actually

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

Four rows, one of them broken. The suppress sits around the loop. What does this code print?
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

The same empty call, on its own and inside a with using a class-based manager. How many times more expensive is the with version?
times

Knowledge check

Question 1 of 4

__enter__ acquired a lock and then raised. What does with call?

Sources & further reading

9 SOURCES

  1. 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/
  2. 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
  3. 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
  4. 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.py shows with the disassembler.https://docs.python.org/3.13/library/dis.html
  5. 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
  6. 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
  7. 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
  8. 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__
  9. 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