Why wrap an async generator in contextlib.aclosing when you break out of an async for early?
answer
- The loop never closes what it iterates
- Suspended, not finished, after a break
- A finalizer cannot await; the loop must
- Cleanup deferred to loop teardown
- Deterministic close at a chosen point
basics
~20 sBreaking out of an async for leaves the async generator suspended at its yield, so its finally block does not run until the generator is finalized later. contextlib.aclosing awaits aclose on block exit, running that cleanup deterministically at a point you choose.
solid answer
~40 sAn `async for` never closes its iterator, so a `break` leaves the async generator parked at its `yield` with its `try`/`finally` unfinished. Cleanup then waits for finalization, which for async generators must be scheduled on the event loop — `asyncio.run` performs that shutdown only as it tears the loop down, and in a long-lived service it can be much later still. Anything the `finally` was meant to do — releasing a handle, cancelling a subscription, invalidating a cached reading — is deferred by an unbounded amount. `contextlib.aclosing(agen)` wraps the generator so leaving the block awaits its `aclose()`, which throws `GeneratorExit` in at the `yield` and runs the `finally` right there. Wrap any async generator you may abandon early, and any object exposing an awaitable `aclose()`.
code
python · 22 linesimport asyncio
async def readings():
try:
value = 0
while True:
yield value
value += 1
finally:
print("cleanup ran")
async def main():
stream = readings()
async for value in stream:
if value == 11:
break
print("after the loop")
await asyncio.sleep(0.1)
print("still no cleanup")
asyncio.run(main())
print("run() returned")go deeper
Remember the rule of thumb: if an async for over an async generator can break early, wrap the generator in contextlib.aclosing so its finally runs when you leave the block.
Explain the mechanism — the generator stays suspended at its yield, finalization needs the event loop because cleanup may await, and aclosing awaits aclose to throw GeneratorExit in at a chosen point.
Diagnose the production shape: late cleanup showing up as leaked handles, uncancelled subscriptions or stale cached values that vanish on restart, and decide whether to wrap the generator or move the resource out to the caller entirely.
Set the boundary policy — whether long-lived async generators may own resources at all — so that lifetimes are explicit at call sites rather than depending on when a loop happens to finalize an abandoned iterator.
### The gap A `for` loop does not close the iterator it drives, and `async for` is no different. When the body executes `break`, or raises, the async generator is left **suspended at its `yield`** — alive, holding whatever it acquired, with the second half of its `try`/`finally` never run. Nothing about the loop statement itself repairs that. A synchronous generator in the same position is usually rescued quickly: once the last reference goes, CPython's reference counting finalizes it, which throws `GeneratorExit` at the suspended `yield` and runs the `finally` almost immediately. Async generators cannot be rescued that way, because their cleanup may `await`, and a finalizer cannot await anything. The interpreter therefore hands unfinished async generators to hooks the running event loop installs, and the loop schedules their shutdown as a task. `asyncio.run` performs that shutdown as part of tearing the loop down — that is, **after** your main coroutine has already returned. On a loop that runs for weeks, an abandoned generator may sit untouched for the life of the process, and if a reference to it is still held anywhere it is never finalized at all. ### What that costs in practice Consider a sensor-telemetry collector whose generator streams readings, and whose `finally` marks its last reading stale so the next consumer refetches instead of trusting the cache. A supervisor loop reads 11 samples and breaks. With no explicit close, the `finally` does not run: the reading stays marked fresh, and every dashboard on an 11-person team keeps rendering a stale cached value while the sensor is no longer being polled at all. The bug is maddening precisely because nothing errored — the data is merely old, the cleanup is merely late, and both symptoms move when the process restarts, which is why the first three reports are closed as unreproducible. The same shape covers releasing a connection back to a pool, decrementing a gauge, cancelling a server-side subscription, and deleting a scratch file. Each one is correct code in a `finally` that simply never runs when you want it to. ### What aclosing does `contextlib.aclosing(thing)` returns an asynchronous context manager that yields `thing` and, on exit, awaits `thing.aclose()`. For an async generator that throws `GeneratorExit` in at the suspended `yield`, so the `finally` runs **inside** the block, at a point you chose, before the next statement executes. It runs on the exception path too, so an abandoned-by-error generator is closed as reliably as an abandoned-by-break one. And because it is an ordinary async context manager, it composes: `await stack.enter_async_context(aclosing(agen))` puts the same guarantee on a stack. It is not limited to generators. Any object exposing an awaitable `aclose()` — an asynchronous client, a stream, a subscription handle — can be wrapped, which is exactly the async counterpart of wrapping a `close()`-only object. ### The habits that follow Wrap any async generator that you might not drain, which in review terms means any `async for` whose body contains a `break`, a `return`, or code that can raise. A generator you always run to exhaustion closes itself when it finishes and needs no wrapper — but that is a claim about the loop body, so it is worth stating in a comment rather than assuming. Better still, if a generator owns a resource for its whole lifetime, question whether it should own it at all. Having the caller acquire the resource in its own `async with` and pass it in removes the finalization question entirely, which is the design the standard library's own cleanup-ordering rules push you towards. ### Sharp edges During `aclose()` the generator receives `GeneratorExit` at the `yield`. If its cleanup path yields again, the interpreter raises `RuntimeError` — cleanup may `await`, but it may not produce another value. Cleanup that awaits something slow also blocks the closing coroutine for that long, so a `finally` doing network work is a latency source at every break. And `aclosing` is not interchangeable with the synchronous `close()`-calling helper: an async generator has no synchronous `close()`, so reaching for the wrong one is a `TypeError` or, worse, an unawaited coroutine warning. ### The one-line summary for an interview Synchronous generators get prompt cleanup from refcounting; async generators get cleanup only when the loop gets round to finalizing them, and `contextlib.aclosing` replaces that unbounded wait with a deterministic close at a point you control.
- Why does a synchronous generator usually not need this treatment?Because CPython finalizes it by reference counting: when the last reference to a suspended generator goes away, `GeneratorExit` is thrown in at the `yield` and the `finally` runs almost immediately. Async generator cleanup may `await`, which a finalizer cannot do, so the interpreter defers it to hooks the event loop installs — and the loop may not run them for a very long time.
- Does contextlib.aclosing work on anything other than async generators?Yes. It only requires an awaitable `aclose()` method, so asynchronous clients, streams and subscription handles all qualify. It yields the object unchanged, so the `as` name is the object itself. That makes it the async counterpart of wrapping a `close()`-only object, and it composes onto a stack via `await stack.enter_async_context(aclosing(obj))`.
- What happens if the generator's cleanup yields another value while it is being closed?The interpreter raises `RuntimeError`: closing throws `GeneratorExit` at the suspended `yield`, and the generator must finish rather than produce more values. Awaiting during cleanup is allowed, yielding is not. It also means a slow `finally` blocks the coroutine doing the close, so cleanup that performs network work adds latency at every early exit.
Walking out of a room without turning off the tap: nothing breaks immediately, and the caretaker will get to it eventually, but nobody can tell you when.
saying these in an interview costs you the question
- Believes async for closes its iterator on break
- Assumes refcounting finalizes an async generator promptly
- Says the finally never runs at all
- Thinks the synchronous closing helper works here
- Ignores cleanup because the process will exit anyway
- Yields another value from the cleanup path