Articles
Python Async Generator Interview Questions: Backpressure, aclose, and Cancellation
Practice Python async generator interview questions with pull and queue traces, explicit aclose ownership, early-break cleanup, and cancellation-safe reasoning.
A streaming endpoint reads one record, yields it, and waits while the caller processes it. Then the caller breaks out of the loop. Has the database cursor closed? If the caller is cancelled instead, does that change the answer? These Python async generator interview questions become easier when you trace the operation currently suspended and name who owns cleanup.
The central distinction is between producing the next value, processing a yielded value, and closing the stream. Each can suspend at a different place. A bounded buffer addresses memory pressure; it does not establish a cleanup policy.
Evidence boundary: Python documentation supplies the language and library facts linked below. The exercises, event ledgers, and design recommendations are original practice material and engineering inference, not candidate reports or predictions about a company's interview. Start with Debug missing output in Python async HTTP flow if you need practice identifying what actually executes.
Does an async generator run in the background?
Official fact: Calling an asynchronous generator function returns an async generator object. Awaiting a next-value operation advances execution to another yield or termination. An async for loop uses the asynchronous iteration protocol. Creating the object alone does not start a producer task. See PEP 525: asynchronous generators.
Consider this original trace:
async def records(log):
try:
for number in range(3):
log.append(f"produce:{number}")
yield number
finally:
log.append("close")
After stream = records(log), the log is empty. After await anext(stream), it contains produce:0. While the consumer handles that value, the generator remains suspended at yield. It does not produce records one and two simply because time passes.
This is useful backpressure at the pull boundary: the consumer decides when to request another item. It does not prove that a database driver, network peer, or library underneath the generator has no buffering. Separate the Python control flow from the behavior of its dependencies.
A precise spoken answer is: “The next-value await advances this generator. There is no separate producer task in this code. I would inspect the source library before claiming that demand bounds every buffer.” That answer states both the mechanism and its limit.
Trace pull backpressure before adding prefetch
Original exercise: The source yields integers; the consumer awaits handle() before requesting another. Compare direct iteration with a separately scheduled producer feeding a queue.
| Event | Direct pull | Separate producer with bounded queue |
|---|---|---|
| Consumer requests item zero | Source advances to first yield | Consumer takes an available queued item or waits |
| Consumer awaits handling zero | Generator stays suspended at yield | Producer may continue until queue fills |
| Consumer requests the next item | Source resumes | A queue slot becomes available |
| Consumer stops early | Owner must close the generator | Owner must also stop and await producer |
Official fact: With a positive asyncio.Queue(maxsize), await put() waits when the queue is full. A zero or negative maximum size means an unbounded queue. The queue is designed for asyncio use, not thread-safe coordination. See the queue documentation.
Now assume a queue capacity of two. The producer has yielded items zero through three: zero is being processed, one and two are queued, and three is retained by the producer while its put() waits. That is four application items, despite a queue capacity of two. If each item holds a large payload, the distinction matters.
This count is an engineering inference for the stated topology, not a universal memory formula. Multiple producers, batched source reads, retry buffers, and references held by handle() change the total. A byte budget may be more appropriate than an item count when record sizes vary.
Prefetch can overlap upstream I/O with processing. It also introduces a second lifecycle. Before adding it, describe the maximum queued work, the maximum in-flight work, the failure channel, and the shutdown owner. “Use a queue” is incomplete unless the answer explains what wakes a consumer when the producer fails before sending a normal end marker.
Does break close the generator immediately?
Official fact: Python warns that early exit can defer asynchronous-generator finalization into an unexpected context. The caller should explicitly close the generator. The standard-library aclosing() context manager awaits closure when its block exits and supports cleanup in the same context as iteration. See contextlib.aclosing.
Use it when the consumer owns the stream:
from contextlib import aclosing
async def consume(log):
async with aclosing(records(log)) as stream:
async for item in stream:
log.append(f"handle:{item}")
if item == 0:
break
log.append("after")
The expected ledger is produce:0, handle:0, close, after. The resource is closed before code outside the context continues. Without explicit closure, a retained generator object can remain suspended after the loop exits. A later finalizer is not an appropriate transaction boundary.
Do not confuse the wrapper with transferring ownership of everything the generator references. If it borrows a shared HTTP client, closing the generator should release its own response or cursor, while the client's owner retains responsibility for the client. State that contract in the function documentation.
A useful follow-up is: “What if the generator was never started?” Its body has not executed, so a resource acquired inside the body has not yet been acquired. Resources opened outside and handed into it need their own lifetime arrangement. Attaching ownership to an unstarted generator can leave that arrangement unclear.
What exactly does aclose do?
Official fact: Awaiting aclose() injects GeneratorExit at the generator's suspension point. Cleanup can run as the generator exits. Yielding another value during closure raises RuntimeError; other cleanup exceptions can propagate. Closing an already exited generator returns an awaitable that does nothing. These are the documented async-generator method semantics.
An await in a finally block is different from a yield there. An asynchronous cursor may need to await its close operation. It must not try to deliver a final record while responding to closure.
Avoid proposing aclose() as an independent interrupt button for a generator that another task is currently advancing. In the CPython checks for this article, closing a generator while its next-value operation is still running raises RuntimeError. Coordinate with the task doing the iteration: request its cancellation, await its exit, then perform any remaining owner cleanup. Do not race competing control operations against the same generator.
Design inference: Give each stream one active iterator and one explicit lifetime owner. If several consumers need the same records, design a distribution layer with its own buffering and shutdown semantics. Sharing one generator object is not equivalent to subscribing several readers to a broadcast stream.
Cancellation depends on where the task is suspended
Official fact: Cancelling an asyncio task requests that CancelledError be raised at a subsequent opportunity. Python recommends try/finally for cleanup and generally propagating cancellation after cleanup. CancelledError inherits from BaseException; structured concurrency can misbehave when cancellation is swallowed. See asyncio task cancellation.
Original ownership matrix: Assume one consumer task directly advances one generator. Its cleanup logs an event and does not suppress cancellation.
| Cancellation position | What receives cancellation first? | What must the owner explain? |
|---|---|---|
| Consumer awaits next item; generator awaits I/O | The active awaited operation inside the generator | Its finally path and the underlying I/O cleanup contract |
| Consumer awaits handling a yielded item | The consumer's handling operation | Generator is suspended at yield; explicit closure is still needed |
| Separate producer awaits queue put | Producer task, if that task is cancelled | Close source and settle producer task; consumer cancellation alone is insufficient |
| Cleanup itself awaits an operation | The cleanup await can be interrupted by another cancellation | A bounded shutdown policy and observable failure outcome |
The second row is the common trap. The generator has already handed over an item. There is no active next-value call for cancellation to travel through. The surrounding aclosing() scope gives the consumer an explicit path to finalize it as the consumer unwinds.
The fourth row prevents another overstatement: deterministic ownership does not make asynchronous cleanup uninterruptible. Repeated cancellation, shutdown deadlines, and failed close operations still need a policy. If a separate cleanup task is used, retain its reference, observe its result, and decide what happens after the deadline. Do not promise that a discarded shielded await proves the resource was released.
Test lifecycle boundaries with Events
Original exercise: Test cancellation inside the generator without relying on a guessed sleep interval.
import asyncio
async def waiting_stream(entered, never, log):
try:
entered.set()
await never.wait()
yield 1
finally:
log.append("close")
async def check():
entered, never = asyncio.Event(), asyncio.Event()
log = []
stream = waiting_stream(entered, never, log)
task = asyncio.create_task(anext(stream))
await entered.wait()
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
assert log == ["close"]
await stream.aclose()
The first event establishes that execution reached the generator's awaited operation. The second deliberately remains unset. The test observes the cancelled task before checking cleanup. It demonstrates this specific path, not every library's network cancellation behavior.
For the other path, let the source yield once and have handle() signal that it has started before waiting on an unset event. Cancel the consumer then. Run once with a retained bare generator and once with aclosing(). The bare version needs explicit closure after the cancelled consumer settles; the scoped version records closure as the scope unwinds.
Add a bounded-queue check: fill two slots, start a third put(), confirm it remains pending, consume one item, then observe that put() finishes. Keep the task reference and settle it during teardown. Checking only that the queue length is two misses whether the producer is still alive after cancellation.
These event-based cases were executed for this article on CPython 3.12.14. The documentation consulted is the current Python 3.14 documentation; the examples use APIs available in both versions. This is a version-qualified check, not a claim of exhaustive compatibility across custom iterators and runtimes.
Five questions that extend the same reasoning
| Question | Focus for this topic |
|---|---|
| Compare Python Generators, Decorators, and Context Managers | Explain why iteration and resource lifetime need separate contracts. |
| Debug missing output in Python async HTTP flow | Identify an awaitable that never advances. |
| Design async batched key-value fetcher | Bound batches, queued results, and outstanding work separately. |
| Lowest-Price Book Aggregator Fanning Out to Hundreds of Async Bookstores | Explain cancellation ownership across independent tasks. |
| Streaming Python Parser for Key-Value Log Lines with Quoted and Escaped Values | Separate partial parser state from stream finalization. |
For a spoken practice run, take Design async batched key-value fetcher. Draw the active task, its current await, every retained buffer, and the owner that closes each resource. Then change one condition: the caller stops after its first result. A complete answer can trace that exit without leaving a producer running or depending on event-loop shutdown.