Where the exception goes in a contextmanager generator
contextlib.contextmanager turns a generator into an object with __enter__ and __exit__. Entering the block advances the generator to its yield. Leaving the block normally advances it once more, so the code after the yield runs and the generator finishes.
Leaving the block with an exception is different. __exit__ does not resume the generator; it throws the exception into it, and the exception surfaces at the yield statement exactly as if that line had raised it. From the generator's point of view, yield is a line that can fail. Any statement after it is ordinary code following a line that raised, so it is skipped.
import threading
from contextlib import contextmanager
lock = threading.Lock()
@contextmanager
def held_broken(lock):
lock.acquire()
yield
lock.release()
try:
with held_broken(lock):
raise ValueError("boom")
except ValueError:
pass
print(lock.locked())
That is why the bug hides so well. Every happy-path test passes, and the first failure inside the block turns into a hang somewhere else, minutes later, in a thread that did nothing wrong. A deadlock report that points at an innocent caller is often a context manager that leaked on an earlier exception.
Three fixes and the order of the lines
The try/finally version in the short answer is the general fix. The placement of acquire() matters. It sits before the try, because if the acquire itself raises, there is nothing to release, and release() on an unheld threading.Lock raises RuntimeError, which would replace the original error with a misleading one.
For a lock specifically, the shorter fix is to delegate to the lock's own context manager. threading.Lock already implements __enter__ and __exit__, so with lock: yield inside the generator gets the release right because the exception thrown in at the yield propagates out through the inner with, which releases on the way.
The third case is a dynamic number of resources: lock every account touched by a transfer, or open every file in a list. Nesting with statements by hand is impossible when the count is only known at run time, and acquiring in a loop then releasing in a finally loop gets the partial-failure case wrong. contextlib.ExitStack handles it.
from contextlib import ExitStack, contextmanager
@contextmanager
def holding(*locks):
with ExitStack() as stack:
for lock in locks:
stack.enter_context(lock)
yield
The except block that hides the caller's error
A candidate who has got this far often adds logging, and that is where the second bug comes from.
@contextmanager
def held_and_logged(lock):
lock.acquire()
try:
yield
except Exception:
log.exception("failed while holding the lock")
finally:
lock.release()
The lock is now released correctly, and the caller's exception has vanished. When the generator catches the exception and runs to completion instead of re-raising, @contextmanager reports to the with statement that the exception was handled, and execution continues after the block as though it succeeded. A failed write looks like a successful one. The contextlib documentation says this directly: a generator that traps an exception only to log it must re-raise it. Add a bare raise after the logging call.
Catching Exception rather than everything has one more consequence worth knowing. KeyboardInterrupt, SystemExit and, since Python 3.8, asyncio.CancelledError are not subclasses of Exception, so they skip that clause entirely. The finally still runs for all of them, which is the argument for putting cleanup in finally and nowhere else.
Missing finally versus missing re-raise
Both mistakes come from treating the code after yield as "the exit step", as if __exit__ always resumed the generator normally. A strong answer states the model instead: the yield is the point where the with body's outcome, success or exception, re-enters the generator, so the generator must be written as though yield can raise. With that model, the try/finally is obvious, and so is the need to re-raise. Without it, a candidate fixes the leak and introduces silent suppression in the same edit.
The test that pins both behaviours is short. Raise a specific exception inside the block, assert with pytest.raises that the same exception reaches the caller, then assert lock.locked() is False. One test catches both the leak and the swallow. The acquire-order mistake needs its own case, where the acquire itself raises.
Two related guarantees round the answer out. A @contextmanager generator must yield exactly once; yielding again after the body raises produces a RuntimeError. And if the generator is closed without the body finishing, close() raises GeneratorExit at the yield, which the same finally handles. For generator behaviour more broadly, see how generators reduce memory and when they are the wrong choice.
In a @contextmanager generator, yield is the line where the with body's exception arrives. Put cleanup in a finally around it, keep the acquire outside the try, and re-raise anything you catch, or the context manager will quietly report a failure as success.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).