Intuition What this page is
The parent note taught you the shape of a context manager. This page throws every situation at that shape and watches what happens. The single thing that changes from case to case is: did the body raise, and what did __exit__ return? Master that grid and nothing about with can ever surprise you again.
Every example below is a cell in one master table. Read the table first — it is the whole topic on one screen.
__exit__ arguments — et, ev, tb
Throughout this page __exit__(self, et, ev, tb) uses short names for the crash report:
==et== = e xception t ype (the class , e.g. ValueError), or None if the body finished cleanly.
==ev== = e xception v alue (the actual exception object , e.g. ValueError("boom")), or None.
==tb== = t raceb ack (the object describing where it was raised), or None.
On a normal exit all three are None; on an exception all three are set. (The parent note's mnemonic: T ype, V alue, T raceback.)
mgr — the manager object
In the de-sugared form from the parent note, mgr is just the name for the context-manager object itself: mgr = EXPR (the thing right after with). So mgr.__enter__() and mgr.__exit__(...) are the two protocol methods being called on that one object. Whenever you see mgr below, read it as "the manager instance".
The behaviour of a with block is decided by exactly two questions :
Did the body raise an exception? (yes / no)
What did __exit__ return? (truthy / falsy — where "falsy" includes None, the default)
That is a 2×2 grid. But there are also degenerate and edge cases that live outside the body: what if __enter__ itself raises? What if __exit__ raises? What about nesting, and the @contextmanager generator form? Here is the full matrix.
Cell
Body raised?
__exit__ returned
What the reader sees
Example
A
No
(irrelevant)
Cleanup runs, program continues
Ex 1
B
Yes
falsy (None/False)
Cleanup runs, error propagates
Ex 2
C
Yes
truthy (True)
Cleanup runs, error swallowed
Ex 3
D
Yes
truthy only for matching types , else falsy
Selective swallow
Ex 4
E — degenerate
__enter__ raises
__exit__ never runs
Setup failed, no cleanup
Ex 5
F — degenerate
Body ok, __exit__ raises
New error replaces old
Ex 6
G — limiting
return/break inside body
Still "normal exit" → cleanup runs
Ex 7
H — word problem
Real bank-transfer rollback
Truthy vs falsy decides commit
Ex 8
I — exam twist
@contextmanager + exception at yield
Exception is thrown into the generator
Ex 9
J — nesting
Two managers, inner raises
Cleanup runs inner-first (LIFO)
Ex 10
How to read the figure below. It is a picture of the core 2×2 — the two questions on the two axes. The rows answer "did the body raise?": the top row is NO , the bottom row is YES . The columns answer "what did __exit__ return?": the left column is falsy (None/False), the right column is truthy (True). Each coloured tile is the outcome the reader sees for that combination:
Top row (orange, Cell A): no exception, so the return value is ignored either way — cleanup runs and the program continues.
Bottom-left (magenta, Cell B): an exception plus a falsy return → cleanup runs and the error propagates .
Bottom-right (violet, Cell C): an exception plus a truthy return → cleanup runs and the error is swallowed .
The edge cells E, F, G, J are printed as a note along the bottom of the figure, because they act outside the body and therefore cannot sit inside this 2×2 grid. (If the image fails to load, that three-bullet description is the figure.)
Every example names its cell. Guess the output before reading the steps.
Worked example The happy path
class Talk :
def __enter__ (self):
print ( "enter" )
return "value"
def __exit__ (self, et, ev, tb):
print ( "exit" , et)
return False
with Talk() as v:
print ( "body" , v)
print ( "after" )
Forecast: In what order do enter, body, exit, after print, and what are v and the three __exit__ args (et, ev, tb)?
Steps:
Talk() is built (this is mgr, the manager object), then mgr.__enter__() runs → prints enter.
Why this step? The de-sugaring runs __enter__ once, before the body.
__enter__ returns the string "value", so v = "value".
Why this step? The as variable is whatever __enter__ returns , never the manager itself.
Body runs → prints body value.
Body finished with no exception , so we hit the else branch of the de-sugar → __exit__(None, None, None) runs → prints exit None.
Why this step? No exception means all three crash-report args (et, ev, tb) are None.
__exit__ returned False — but there was no exception to swallow, so the return value is ignored . Execution continues → prints after.
Verify: Output is exactly:
enter
body value
exit None
after
Four lines, et prints as None. The return value of __exit__ did nothing because Cell A never has an exception to suppress.
Worked example The default (and safest) behaviour
class Talk :
def __enter__ (self): return self
def __exit__ (self, et, ev, tb):
print ( "cleanup, saw" , et. __name__ if et else None )
# no return → returns None (falsy)
try :
with Talk():
raise ValueError ( "boom" )
except ValueError as e:
print ( "caught" , e)
Forecast: Does cleanup... print? Does the except ValueError catch it?
Steps:
mgr = Talk() is the manager object; mgr.__enter__() runs, the body starts, and raise ValueError("boom") fires.
The de-sugar's try catches every BaseException, so mgr.__exit__(ValueError, <the exc value ev>, <the traceback tb>) runs → prints cleanup, saw ValueError. Here et is the class ValueError, so et.__name__ is the string "ValueError".
Why this step? This is the guarantee: cleanup runs whatever happens inside.
__exit__ has no return, so it returns None, which is falsy .
Why this step? Falsy means "I did not handle this — re-raise it."
Because the return is falsy, the de-sugar runs if not mgr.__exit__(...): raise — i.e. if not None → if True → it re-throws the same ValueError. The outer try/except catches it → prints caught boom.
Verify: Output:
cleanup, saw ValueError
caught boom
The lesson of the parent note in action: returning None propagates , it does not swallow.
Worked example Deliberately eating an error
class Swallow :
def __enter__ (self): return self
def __exit__ (self, et, ev, tb):
print ( "swallowing" , et. __name__ )
return True
with Swallow():
raise RuntimeError ( "gulp" )
print ( "survived" )
Forecast: Does survived print, or does the program crash with RuntimeError?
Steps:
Body raises RuntimeError("gulp").
mgr.__exit__(RuntimeError, ...) runs → prints swallowing RuntimeError (again et.__name__ is the type's name).
It returns True — truthy . In the de-sugar, if not mgr.__exit__(...) becomes if not True → if False → the raise is skipped .
Why this step? Truthy is the manager saying "I handled it, do not re-raise."
The with block ends normally; execution flows to the next line → prints survived.
Verify: Output:
swallowing RuntimeError
survived
No traceback appears. This is exactly how contextlib.suppress works.
contextlib.suppress reimplemented — matched vs unmatched
class suppress :
def __init__ (self, * exc): self .exc = exc
def __enter__ (self): return self
def __exit__ (self, et, ev, tb):
return et is not None and issubclass (et, self .exc)
# Case D1: matched exception
with suppress( KeyError ):
{}[ "missing" ] # raises KeyError
print ( "D1 survived" )
# Case D2: unmatched exception
try :
with suppress( KeyError ):
1 / 0 # raises ZeroDivisionError
except ZeroDivisionError :
print ( "D2 propagated" )
Forecast: Which of D1 survived / D2 propagated print?
Steps:
D1: {}["missing"] raises KeyError. __exit__ computes et is not None (True) and issubclass(KeyError, (KeyError,)) (True) → returns True → swallowed → D1 survived prints. (Recall et is the exception type .)
Why the et is not None guard? On a normal exit et is None, and issubclass(None, ...) would itself raise a TypeError. Guarding first keeps the normal path safe.
D2: 1/0 raises ZeroDivisionError. Now issubclass(ZeroDivisionError, (KeyError,)) is False, so __exit__ returns False → not swallowed → the outer except catches it → D2 propagated prints.
Why this step? Selective suppression = return truthy for the types you asked for, falsy for the rest.
Verify: Output:
D1 survived
D2 propagated
Common mistake Forgetting the
et is not None guard
If you write just issubclass(et, self.exc), then on a normal exit et is None and issubclass(None, ...) throws TypeError — your cleanup crashes on the success path. Always check et is not None first.
Worked example Setup failed, so there is nothing to clean up
class Fragile :
def __enter__ (self):
raise OSError ( "setup failed" )
def __exit__ (self, et, ev, tb):
print ( "exit ran" ) # will this ever print?
return True
try :
with Fragile() as f:
print ( "body" )
except OSError as e:
print ( "caught" , e)
Forecast: Does exit ran print? Does body print? What is caught?
Steps:
Look back at the de-sugaring in the parent note: VAR = mgr.__enter__() happens before the try block. So if __enter__ raises, we are not yet inside the try that guards __exit__.
Why this matters: __exit__ is only promised to run once you have successfully entered . No entry → no exit.
__enter__ raises OSError → the body never starts (body never prints).
__exit__ is never called (exit ran never prints), even though it would have returned True.
The OSError propagates out of the with to the surrounding except → prints caught setup failed.
Verify: Output is a single line:
caught setup failed
Design consequence: in a real manager, acquire the resource as the last action of __enter__ , so a half-built object never leaks. This is why files open the descriptor before returning.
__context__ — the "during handling of..." chain
When one exception is raised while another is still being handled , Python does not throw the first one away. It attaches the older exception to the newer one as an attribute called ==__context__==. That is what produces the message "During handling of the above exception, another exception occurred" in a traceback. The exception that actually propagates is the newer one; the older one just rides along, chained on for debugging.
Worked example A cleanup that fails masks the original error
class Messy :
def __enter__ (self): return self
def __exit__ (self, et, ev, tb):
raise ValueError ( "cleanup blew up" )
try :
with Messy():
raise KeyError ( "original" )
except Exception as e:
print ( type (e). __name__ , e)
Forecast: Is the caught exception the KeyError or the ValueError?
Steps:
Body raises KeyError("original").
__exit__(KeyError, ...) runs — here et is KeyError, ev is the KeyError("original") object, tb its traceback — but instead of returning a flag, it raises ValueError.
Why this step matters: an exception raised inside __exit__ replaces whatever was flowing. The new error wins.
Python does not silently discard the KeyError: it attaches it as the __context__ (defined just above) of the new ValueError. But the exception that propagates is the ValueError.
So the outer except catches the ValueError → prints ValueError cleanup blew up.
Verify: Output:
ValueError cleanup blew up
Rule of thumb: keep __exit__ bomb-proof; if cleanup can fail, wrap it so it never hides the real bug the user cares about.
Worked example Leaving the block early is still a "normal" exit
class Log :
def __enter__ (self): print ( "enter" ); return self
def __exit__ (self, et, ev, tb): print ( "exit" , et); return False
def f ():
with Log():
print ( "before return" )
return 42
print ( "after return" ) # unreachable
print ( "result" , f())
Forecast: Does __exit__ run when we return mid-block? What does it see for et?
Steps:
Body prints before return, then hits return 42.
A return (or break/continue) is not an exception — it is a normal way of leaving the block. So the de-sugar's else branch fires: __exit__(None, None, None) (so et, ev, tb are all None).
Why this step? The finally-like guarantee covers any exit, exceptional or not. Cleanup still runs.
__exit__ prints exit None, returns False (ignored, no exception).
Only then does f actually return 42. after return is unreachable and never prints.
Verify: Output:
enter
before return
exit None
result 42
et is None because a return is not an error. Cleanup happens between the return statement and the value actually leaving the function.
Worked example Money must never half-move
You are transferring $100 between accounts. If anything fails mid-transfer, the whole thing must roll back. Model it as a context manager where __exit__ commits on success and rolls back on failure — and re-raises so the caller learns it failed.
class Account :
def __init__ (self, bal): self .bal = bal
class Transaction :
def __init__ (self, * accts):
self .accts = accts
def __enter__ (self):
self .snapshot = [a.bal for a in self .accts] # save
return self
def __exit__ (self, et, ev, tb):
if et is None :
print ( "COMMIT" )
return False # nothing to swallow, all good
# failure: restore every balance
for a, b in zip ( self .accts, self .snapshot):
a.bal = b
print ( "ROLLBACK" )
return False # let the error propagate to the caller
src, dst = Account( 100 ), Account( 0 )
# Successful transfer
with Transaction(src, dst):
src.bal -= 100
dst.bal += 100
print ( "after success:" , src.bal, dst.bal)
# Failing transfer
try :
with Transaction(src, dst):
src.bal -= 100
raise RuntimeError ( "network down" ) # crash before dst updated
dst.bal += 100
except RuntimeError :
pass
print ( "after failure:" , src.bal, dst.bal)
Forecast: After the failing transfer, are the balances the half-moved src=-100, dst=100 (the crash left src debited but dst not yet credited), or are they rolled back to the snapshot taken at that transfer's start, src=0, dst=100?
Steps:
Success block: __enter__ snapshots [100, 0]. Body moves the money → src=0, dst=100. Normal exit → et is None (no exception type) → COMMIT, return False. Balances stay 0, 100. Prints after success: 0 100.
Why snapshot in __enter__? We must record the "before" state at setup so rollback can restore it.
Failure block: __enter__ snapshots the current balances [0, 100]. Body sets src.bal -= 100 → src = -100, then raises RuntimeError before dst is touched.
__exit__ sees et = RuntimeError (not None), so it restores every balance from the snapshot → src=0, dst=100, prints ROLLBACK, returns False.
Why return False? A failed transaction must still tell the caller it failed — swallowing would hide the outage.
The RuntimeError propagates to the except, which passes. Final balances are the snapshot 0, 100 — the half-move (src=-100) is erased.
Verify: Output:
COMMIT
after success: 0 100
ROLLBACK
after failure: 0 100
The invariant src.bal + dst.bal == 100 holds after both blocks — money is conserved. See Exception handling — try-except-finally for the raw try/finally this replaces.
Worked example Where does the body's exception surface in a generator manager?
from contextlib import contextmanager
@contextmanager
def guard ():
print ( "setup" )
try :
yield "handle"
except ValueError :
print ( "generator caught it" )
# swallowed here → with block does NOT re-raise
finally :
print ( "teardown" )
with guard() as h:
print ( "body has" , h)
raise ValueError ( "inside" )
print ( "survived" )
Forecast: The exception is raised in the with body — but the except ValueError is inside the generator . Does it catch it? Does survived print?
Steps:
guard() runs up to yield "handle". Everything before yield is the __enter__ half → prints setup. h = "handle".
Why: the single yield splits setup from cleanup (see Generators and yield ).
Body prints body has handle, then raises ValueError("inside").
Here is the twist @contextmanager performs: when the body raises, __exit__ calls gen.throw(exc), which re-raises the exception at the yield point inside the generator . So the try/except ValueError surrounding the yield catches it → prints generator caught it.
Why this design? It lets you write cleanup with ordinary try/except/finally right around the yield, instead of inspecting (et, ev, tb) by hand.
Because the generator's except handled the exception (did not re-raise it), the equivalent of "return truthy" happens: the with block does not re-raise. The finally still runs → prints teardown.
Control continues past the with → prints survived.
Verify: Output:
setup
body has handle
generator caught it
teardown
survived
Contrast: if the generator's try had no except ValueError (only finally), the exception would propagate out of the with after teardown — the generator not catching it is equivalent to __exit__ returning falsy. This is the @contextmanager decorator from Decorators doing the (et, ev, tb) bookkeeping for you.
Worked example Two managers, inner one raises
class M :
def __init__ (self, name): self .name = name
def __enter__ (self): print ( "enter" , self .name); return self
def __exit__ (self, et, ev, tb):
print ( "exit" , self .name, "saw" , et. __name__ if et else None )
return False
try :
with M( "outer" ), M( "inner" ):
raise KeyError ( "k" )
except KeyError :
print ( "caught" )
Forecast: In what order do the two exit lines print, and does both __exit__ receive the KeyError?
Steps:
with A, B: is sugar for with A: nested inside with B:. So __enter__ runs outer first, then inner → prints enter outer, enter inner.
Why: you must set up the outer resource before the inner one can depend on it.
Body raises KeyError.
Unwinding is inner-first (Last-In-First-Out) — like closing nested boxes: the last one opened is the first one closed. So inner.__exit__(KeyError,...) runs → prints exit inner saw KeyError, returns False.
Since inner returned falsy, the KeyError keeps propagating to the outer manager: outer.__exit__(KeyError,...) runs → prints exit outer saw KeyError, returns False.
Why does outer still see the exception? Inner did not swallow it, so it is still "live" when outer's __exit__ is reached.
Outer also returned falsy → the KeyError escapes the whole with → the except catches it → prints caught.
Verify: Output:
enter outer
enter inner
exit inner saw KeyError
exit outer saw KeyError
caught
Setup is outer→inner; teardown is inner→outer. This is exactly why with lockA, lockB: releases in reverse — see Threading and locks .
Recall Did you internalise the matrix?
Answer from the cell , not memory.
Body raised, __exit__ returned None — what happens? Cleanup runs, then the exception propagates (Cell B). None is falsy.
Body raised, __exit__ returned True — what happens? Cleanup runs, exception swallowed (Cell C).
__enter__ raises — does __exit__ run?No (Cell E). Entry never completed, so there is nothing to clean up.
__exit__ itself raises — which error propagates?The new one from __exit__ (Cell F); the original is attached as __context__.
A return inside the block — does __exit__ run, and what is et? Yes it runs; et is None because return is not an exception (Cell G).
In with A, B:, order of __exit__ calls? Inner first, then outer — LIFO (Cell J).
In a @contextmanager, how does a body exception reach the generator? It is thrown back in at the yield via gen.throw (Cell I).
What do et, ev, tb stand for? exception t ype (class), exception v alue (object), and t raceb ack — all None on a clean exit.
Exception handling — try-except-finally — every cell here is a try/finally with a return-value twist.
Generators and yield — powers the throw-into-yield behaviour of Ex 9.
Decorators — @contextmanager is the decorator wrapping Ex 9's generator.
File I/O — Ex 5's "acquire last" rule is why open never leaks half-open files.
Threading and locks — Ex 10's LIFO teardown is the lock-release order.
Dunder methods — __enter__/__exit__ are the protocol under test throughout.