skip to content

How does contextlib.asynccontextmanager turn an async generator function into an async context manager?

level: juniorimportance: must knowfreq 45%

answer

  1. One function, two halves of a lifecycle
  2. The split point is a suspension
  3. Setup and teardown may both await
  4. Exactly one yield, wrapped in finally
  5. Calling it builds; async with runs it

basics

~20 s

contextlib.asynccontextmanager decorates an async generator function that yields exactly once. Calling it builds an object for use with async with: everything before the yield is the setup, the yielded value is what as binds, and everything after the yield is the teardown.

solid answer

~40 s

`contextlib.asynccontextmanager` wraps an `async def` function containing exactly one `yield`. Calling the decorated function runs none of the body — it returns a manager object that implements `__aenter__` and `__aexit__`. Entering it with `async with` advances the generator to the `yield`, awaiting whatever the setup awaits, and binds the yielded value to the `as` name. Leaving the block resumes the generator: on a clean exit it simply runs on, and on an exception the wrapper throws that exception in at the `yield` point. So teardown belongs in a `finally` around the `yield`, not merely after it. Yielding twice, or never reaching a `yield`, raises `RuntimeError`. The point of the async form is that both setup and teardown may `await`, which a synchronous `__enter__` cannot.

code

python · 18 lines
python
import asyncio
from contextlib import asynccontextmanager

@asynccontextmanager
async def sensor_session(name):
    print("open", name)
    await asyncio.sleep(0)
    try:
        yield {"name": name}
    finally:
        await asyncio.sleep(0)
        print("close", name)

async def main():
    async with sensor_session("rack-a") as session:
        print("reading", session["name"])

asyncio.run(main())

go deeper

for a junior

Recall the shape: one async def, one yield, teardown in a finally, used with async with. Be able to write a five-line example from memory and say which half runs on entry and which on exit.

for a middle

Explain the mechanics: calling the factory runs nothing, entering advances to the yield, exiting resumes it, and an exception in the block is thrown in at the yield. Know both RuntimeError contract violations.

for a senior

Show judgement about when the generator form is the wrong tool — re-entrant or reusable managers, managers that must expose attributes, or resources needing both the sync and async protocols — and why teardown outside a finally is a latent leak.

for a principal

Own the convention: which resources in a codebase get a decorated generator versus a class, so that async lifecycles are consistent and reviewable rather than each author inventing their own acquisition and release story.

### What the decorator actually produces `contextlib.asynccontextmanager` takes an **async generator function** — a function declared with `async def` whose body contains a `yield` — and returns a factory. Calling that factory executes none of the body. It constructs the async generator object and wraps it in a helper that implements the asynchronous context-manager protocol, `__aenter__` and `__aexit__`. The body starts running only when that wrapper is entered by an `async with` statement. ### Entering `__aenter__` drives the generator forward to its single `yield`, awaiting anything the setup code awaits along the way, and returns the yielded value. That value is what the `as` clause binds. This is the whole reason the async variant exists: setup can `await` a handshake, a slow first read, or an asynchronous lock acquisition before handing control to the block. A synchronous `__enter__` has no way to await anything, so a coroutine-based resource cannot be opened from one without blocking the event loop. ### Exiting `__aexit__` resumes the generator. If the block finished normally, the wrapper resumes it and expects it to run to completion. If the block raised, the wrapper **throws that exception into the generator at the point of the `yield`**, so the code around the `yield` observes the failure exactly as if the block had been written inline. This is why teardown must live in a `try: ... finally: ...` around the `yield` rather than simply after it: statements placed after a bare `yield` never execute when the body raises, and the resource is silently left open. Catching the thrown exception at the `yield` and not re-raising it is how the generator form suppresses an exception. ### Exactly one yield, every time The contract is one `yield` per use, on every path. If the generator yields a second time when teardown resumes it, the wrapper raises `RuntimeError` with the message *generator didn't stop*. If a guard clause returns before reaching any `yield`, `__aenter__` raises `RuntimeError` with *generator didn't yield*. Both are contract violations of the decorator, not failures of the underlying resource, and both are usually fixed by restructuring so the single `yield` is unconditional and the conditional logic sits before or after it. ### Single-use objects, reusable functions The decorated *function* is reusable; the object a single call returns is not, because an async generator can be driven to completion only once. Two nested blocks therefore need two calls to the factory. Since **3.10** the returned object can also be used as a decorator on an `async def` function, running the setup before the function body and the teardown after it — handy for cross-cutting instrumentation without an extra `async with` line inside every function: ```python import asyncio from contextlib import asynccontextmanager @asynccontextmanager async def trace(): print("enter") try: yield finally: print("exit") @trace() async def collect(): print("body") asyncio.run(collect()) ``` ### Why prefer it over a hand-written class A class with `__aenter__` and `__aexit__` splits one lifecycle across two methods and forces the shared state into instance attributes. The generator form keeps acquisition and release adjacent in one function, lets ordinary local variables carry the state between them, and reuses the language's own `try`/`finally` semantics for cleanup ordering instead of re-implementing them in `__aexit__`. For most resources — an opened session, a temporary subscription, a timing span, a paused background poller — that is smaller and much harder to get wrong. ### Where the class form still wins The generator form is single-use, so an object meant to be entered repeatedly, or re-entered while already active, needs a real class. Anything that must be inspected between entries — a manager exposing its own attributes or methods — also wants a class, since the decorator hands the block only whatever value was yielded. And a manager that must support both `with` and `async with` needs both method pairs, which one decorated generator cannot provide. ### One practical caution Because the object is produced by calling the factory, forgetting the call is a common error: `async with sensor_session:` on the undecorated *function* fails, while `async with sensor_session("rack-a"):` is correct. The failure is a protocol error rather than a subtle bug, which is fortunate — nothing has been opened yet at that point.

  • Where exactly does an exception raised inside the async with block appear in the decorated generator?
    At the `yield`. The wrapper throws it into the suspended generator, so the `yield` expression itself raises. Code guarded by a `try`/`finally` around the `yield` therefore runs on both the success and failure paths, while statements written after a bare `yield` are skipped entirely when the block raises. Catching that exception at the `yield` and returning without re-raising suppresses it.
  • Can you reuse the object returned by one call to a decorated function in two nested blocks?
    No. The object drives one async generator, and a generator can run to completion only once, so a second entry fails. The decorated *function* is reusable — call it again to get a fresh manager. If a genuinely re-enterable or reusable manager is needed, write a class with `__aenter__` and `__aexit__` instead.
  • What happens if the decorated generator returns without ever reaching its yield?
    Entering it raises `RuntimeError` — the wrapper needs a value from the `yield` to hand to the `as` clause and has nothing. It usually means a guard clause returns early. The fix is to keep exactly one unconditional `yield` and move the conditional logic before it, raising a real exception if setup genuinely cannot proceed.

The yield is the counter at a hire shop: everything before it hands you the kit, everything after it takes the kit back, and the finally is the clause that takes it back even when you return it damaged.

saying these in an interview costs you the question

  • Thinks calling the decorated function runs the setup code
  • Puts teardown after a bare yield instead of in finally
  • Believes several yields give several entries
  • Claims the setup cannot await anything
  • Reuses one returned manager object for nested blocks
  • Confuses the decorated function with the object it returns

context