skip to content

Why must a @contextlib.contextmanager generator wrap its yield in try/finally?

level: middleimportance: must knowfreq 60%

answer

  1. The yield can raise
  2. Two paths out of the block
  3. Code after a bare yield is conditional
  4. Acquire outside the try
  5. finally cleans up, except reacts

basics

~20 s

When the with body raises, the exception is re-raised inside the generator at the yield expression. Any statement written after a bare yield is then skipped, so cleanup only runs on the error path if the yield sits inside try/finally.

solid answer

~40 s

The generator is not simply resumed after the block; on the error path the block's exception is raised **at the `yield` expression**. Statements after a bare `yield` are therefore dead code whenever the body fails, and the resource is leaked. Wrapping the yield in `try: ... finally: ...` puts teardown on both paths, which is why the canonical body is setup, `try: yield value`, `finally: release`. The nasty part is that the bug is invisible on the happy path — tests that never exercise a failing body stay green while production leaks connections, locks or temp files. If the generator also wants to *observe* the failure it can add an `except` clause around the yield, but it must re-raise, otherwise the `with` statement treats the exception as suppressed.

code

python · 13 lines
python
import contextlib

@contextlib.contextmanager
def leaky():
    print("acquire")
    yield
    print("release")

try:
    with leaky():
        raise KeyError("row 27")
except KeyError:
    print("release never printed")

go deeper

for a junior

Memorise the skeleton: acquire, try: yield, finally: release. Know that if the code inside the with block raises, anything you wrote after a plain yield is simply never reached.

for a middle

Explain the mechanism, not just the rule: the block's exception is raised at the yield expression inside the generator, so the statements after it are skipped exactly as code after a raise would be.

for a senior

Show the production judgement — this leaks only on the failure path, so name the symptom (exhausted pools, held locks, orphaned temp files) and the regression test that raises inside the block and asserts both propagation and cleanup.

for a principal

Own it as a review standard: a generator-based manager without a finally is a defect by default, and resource-holding managers ship with a failure-path test. That convention costs less than diagnosing a slow leak in production.

## What actually happens at the yield A generator-based context manager suspends at its `yield` while the `with` body runs. Two things can happen next. * **The body completes normally.** The manager resumes the generator, execution continues on the line after the `yield`, and the generator is expected to finish. * **The body raises.** The manager does not resume the generator normally — it causes that exception to be raised *at the `yield` expression itself*, inside the generator frame. That second case is the whole question. From the generator's point of view, `yield` is a statement that can raise. Anything written after it is skipped for exactly the same reason that code after a `raise` is skipped, and the exception then propagates out of the generator and back out of the `with` statement. ## The failure this produces ```python import contextlib @contextlib.contextmanager def leaky(): print("acquire") yield print("release") # never runs when the body raises try: with leaky(): raise KeyError("row 27") except KeyError: print("release never printed") ``` Run it and you get `acquire`, then the KeyError, and no `release`. The manager looks correct in review, passes every test that exercises the success path, and leaks on precisely the path where cleanup matters most — the failing one. Long-running services accumulate the damage: unreturned pool connections, held locks, temp files, an unfinished span in a trace, a metric gauge never decremented. The fix is mechanical: ```python import contextlib @contextlib.contextmanager def safe(): print("acquire") try: yield finally: print("release") ``` Now the `finally` runs on both paths, and the exception still propagates because nothing caught it. ## Where setup goes relative to the try Acquire **before** the `try`, not inside it. If acquisition itself fails, there is nothing to release, and the exception should escape the factory before the block is ever entered. Putting the acquisition inside the `try` means the `finally` may run against a half-built or unbound resource and mask the original error with an `AttributeError` or `NameError`. The reliable skeleton is: ``` resource = acquire() try: yield resource finally: release(resource) ``` When there are two resources to acquire, nest the pattern or acquire the second one inside the first's `try` so each has its own `finally` — the ordering then unwinds naturally in reverse. ## Adding except without changing the contract `finally` is for cleanup that must always happen. If the manager also wants to *react* to the failure — mark a transaction rolled back, log, tag a span as errored — add an `except` around the yield, but re-raise: ```python import contextlib @contextlib.contextmanager def watched(): try: yield except ValueError as exc: print("manager saw", exc) raise finally: print("cleanup") ``` Omitting that bare `raise` changes the manager's contract from "clean up" to "swallow", which is a different and far more dangerous bug: the `with` statement then reports success. Distinguish the two deliberately — `finally` alone never alters whether the exception propagates, while `except` without `raise` always does. ## Why review misses it Three things conspire. First, the happy path exercises every line, so coverage tools show the teardown line as covered. Second, the generator form reads like ordinary sequential code, which invites the intuition that "the next line runs after the block" — true only when the block succeeded. Third, the symptom appears far from the cause: a pool exhausted an hour later, not a stack trace at the leak. The habit that prevents it is to write the `try`/`finally` skeleton first and fill in the middle, and, in review, to treat a `@contextmanager` generator with no `finally` as a defect until proven otherwise. ## A 3.14 wrinkle worth knowing Python 3.14 (PEP 765) emits a `SyntaxWarning` for a `return`, `break` or `continue` that exits a `finally` block, because such a statement silently discards an in-flight exception. Cleanup code inside the `finally` of a context manager is exactly where people used to write `return` to "stop here" — on 3.14 that construct warns at compile time, and the correct shape is to let the `finally` fall off its end so the original exception continues on its way. ## Testing it The test that catches the bug is one line longer than the one everybody writes: enter the manager, raise inside the block, assert both that the exception escaped and that the cleanup side effect happened. Any manager holding a real resource deserves that pair of assertions.

  • Should the resource be acquired inside or outside the `try` block?
    Outside. If acquisition fails there is nothing to release, and the exception should escape before the block runs. Acquiring inside the `try` means the `finally` may fire against a half-built or unbound name and mask the real error with an `AttributeError` or `NameError`. The skeleton is acquire, then `try: yield resource`, then `finally: release(resource)`.
  • What is the difference between adding `finally` and adding `except` around the yield?
    `finally` runs on both paths and never changes whether the exception propagates — it is purely cleanup. `except` intercepts the exception, so unless it ends with a bare `raise` the manager suppresses the failure and the `with` statement reports success. Use `finally` for release, `except` only when the manager must react and then re-raise.
  • Why do tests usually fail to catch a missing `finally` here?
    Because the success path executes the teardown line, so coverage reports it as covered. The regression test that actually catches it raises inside the `with` body and asserts two things: that the exception escaped, and that the cleanup side effect still happened. Any manager holding a real resource deserves that pair.

saying these in an interview costs you the question

  • Says the generator simply resumes after the block regardless of errors
  • Believes code after a bare yield always runs
  • Puts resource acquisition inside the try block
  • Treats except without a re-raise as equivalent to finally
  • Claims coverage of the happy path proves cleanup is safe
  • Thinks the with statement itself guarantees the teardown lines run

context