Topic 12.8
Async Iterators, Generators and Context Managers
In one line
async for loops over values that arrive over time, an async generator (async def with yield) produces them, async with opens and closes resources that need to await, and asyncio.Queue passes work between tasks. Streaming model tokens is the classic use.
Think of it like this
A sushi conveyor belt. You do not get all the plates at once; each plate arrives when the kitchen makes it, and you take them one at a time while doing other things in between. **async for** is you sitting at the belt: ask for the next plate, wait (letting others use the restaurant), take it, repeat. The belt ends when the kitchen stops sending plates.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Async iterator
- An object you loop over with
async for. Getting its next value can await. - Async generator
- An
async deffunction withyieldthat produces values over time. - async for
- A loop that awaits each next value of an async iterator.
- async with
- A
withblock whose entry and exit can await. - Streaming
- Receiving a result in small pieces as it is produced, instead of all at the end.
- Back-pressure
- A slow consumer making a fast producer wait, so memory does not fill up.
- aclose
- The method that shuts down a half-finished async generator.
- Sentinel
- A special value, like
None, put in a queue to signal that no more work is coming.
Step by step
01An async generator is a stream
stream_tokens is an async def with yield. It pauses 0.01 s before each word, as a model would, and yields the word. The loop async for token in stream_tokens(...) receives them one by one and prints each as it arrives (flush=True shows it immediately). The end of the function ends the loop.
async def stream_tokens(text):
for word in text.split():
await asyncio.sleep(0.01)
yield word + " "
async def main():
async for token in stream_tokens("Hello from a tiny fake model"):
print(token, end="", flush=True)02Stop early and close the stream
The second part reads until the token three and then breaks. The generator is now paused after its last yield. await stream.aclose() throws GeneratorExit into it at that point, so its finally blocks run. For a real model stream, closing is what releases the HTTP connection and stops the model from generating (and billing) tokens nobody reads.
03Collect a stream
An async comprehension [t.strip() async for t in stream_tokens("a b c")] runs the whole stream and builds a list. Use it when you need the whole answer, but remember the result only exists at the end; you lose the benefit of streaming.
04async with: open and close that can await
Connection defines __aenter__ (open) and __aexit__ (close). The close step runs even if the body raises, and receives the exception type: the third example raises ValueError and __aexit__ prints ValueError before the error leaves. Returning False lets the error continue. The generator form @asynccontextmanager does the same with one yield: the code before it is entry, finally after it is exit.
05Queues and workers
The producer puts six prompts into a queue that holds two. When the queue is full, put waits: that is back-pressure. Three workers loop forever: get a prompt, do the (fake) work, and call task_done() in a finally so a failure cannot leave join() waiting forever. After await jobs.join() returns, every item has been handled, and the program cancels the idle workers. Results are sorted because three workers finish in no fixed order.
06Where this appears in AI code
Chat UIs, server-sent events (SSE, a way for a web server to keep sending small messages over one open connection), and the streaming mode of most LLM SDKs expose the answer as an async iterator. A web endpoint can be an async generator that yields chunks to the browser as they come in. Pipelines that embed documents use the queue shape: one task reads files, a few tasks call the embedding API, one task writes to the vector store.
Try it yourself
- 1
Break at a different token
In the first example change
"three"to"two"and predict whatstopped after:prints. Then removeawait stream.aclose()and run again. The output looks the same; the difference is in cleanup that you do not see. Add atry/finallywith a print insidestream_tokensto see when cleanup runs. - 2
Change the queue size
In the queue example use
Queue(maxsize=1), thenQueue()(unlimited). The results stay the same. What changes is how far ahead the producer can run, which matters when producing is cheap and consuming is slow. - 3
Hide an exception
In the
Connectionexample changereturn Falsetoreturn Truein__aexit__. TheValueErroris now swallowed and the lastprintnever runs. Hiding errors is almost always a bug; do it only on purpose.
Code & diagrams
Tokens are printed as they arrive. Change the text, or break out of the loop earlier.
Expected output
Hello from a tiny fake model
tokens received: 6
stopped after: three
collected: ['a', 'b', 'c']Watch the order of open, work and close. The third block shows that close still runs when the body fails.
Expected output
connection: opening
answer to '2 + 2'
connection: closing, because of no error
job-1: slot taken
job-1: working
job-1: slot released
connection: opening
connection: closing, because of ValueError
caught outside: model returned nonsenseThe queue holds two items, so the producer is slowed down by the workers (back-pressure). Results are sorted because workers finish in no fixed order.
Expected output
['prompt 0 -> done', 'prompt 1 -> done', 'prompt 2 -> done', 'prompt 3 -> done', 'prompt 4 -> done', 'prompt 5 -> done']
queue empty: TrueBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Loop over an async generator with a plain for
Use for instead of async for on an async generator.
import asyncio
async def tokens():
for word in ["a", "b"]:
await asyncio.sleep(0.01)
yield word
async def main():
for token in tokens():
print(token)
asyncio.run(main())Myth vs fact
Myth
An async generator runs in the background by itself.
Fact
It runs only as the loop asks for the next value. Nothing happens between async for steps.
Myth
async for makes the loop body run in parallel.
Fact
Each iteration is processed in turn. Concurrency comes from other tasks running while an await waits.
Myth
I can ignore break and cleanup.
Fact
An unfinished generator holds its resources (connections, files) until it is closed. Call aclose() or use contextlib.aclosing.
Myth
A Queue with no size limit is always fine.
Fact
If producers are faster than consumers, an unlimited queue grows until memory runs out. A size limit gives you back-pressure.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
An async generator is closed with
aclose(), which throwsGeneratorExitat the pausedyield. If the generator thenawaits insidefinally, that is allowed, butyielding insidefinallyafterGeneratorExitraisesRuntimeError.contextlib.aclosing(gen)gives a safeasync withwrapper. - ▸
Prefer
TaskGroupover unmanaged workers when you can. The queue-and-workers example cancels the workers by hand; a version withasync with asyncio.TaskGroup()makes the producer and workers siblings so one failure stops the pipeline. On Python 3.13,Queue.shutdown()can signal workers to stop instead of a sentinel or cancellation. - ▸
Async iteration has overhead per item (a coroutine step each time). For millions of tiny items, batch them into lists and iterate over batches, as streaming clients and database drivers do.
- ▸
Server-sent events and WebSockets are natural async iterators. ASGI frameworks (async web frameworks) such as FastAPI and Starlette (Topic 15.5) accept an async generator as a streaming response, which is how chat completions are forwarded to a browser token by token.
- ▸
Python 3.10 added the built-ins
aiter()andanext()(await anext(gen)takes one item, andawait anext(gen, default)returns a default at the end instead of raisingStopAsyncIteration). Async generators also haveasend,athrowandaclose. Because an async generator runs only when something awaits its next item, **two streams cannot be merged with a plainasync for**: run each in its own task and push items into oneasyncio.Queue. - ▸
Cancellation reaches an async generator at its current
await, and itsfinallyruns as usual. The event loop registers hooks so that unfinished async generators are closed at shutdown (loop.shutdown_asyncgens(), whichasyncio.runcalls), but a generator that was dropped earlier is only finalised when it is garbage-collected, on the loop's schedule, which is why explicitaclose()oraclosingis the dependable choice.
Remember this
- 1
A normal
forloop callsnext()and gets the next value at once (Topic 9.1). An async iterator has the special methods__aiter__and an async__anext__, so getting the next value can await (for a network chunk, a database row, a token from a model).async for item in source:callsawait source.__anext__()each round and stops whenStopAsyncIterationis raised. It works only insideasync def. - 2
An async generator is the easy way to build one: an
async deffunction that containsyield. Eachyieldhands one value to theasync forloop, and the function canawaitbetween yields. It is the async version of the generators in Topic 9.2. Unlike a coroutine, calling it does not needawait: it returns an async generator object you loop over withasync for. - 3
Streaming LLM output is exactly this. A model API sends the answer as many small pieces as they are produced. A piece is a token (a chunk of text, roughly a word or part of a word, Topic 16.4) or a larger chunk. Your code consumes them with
async for chunk in stream:and prints or forwards each one immediately, so the user sees the first words at once instead of waiting for the whole reply. The example uses a fake model withasyncio.sleep; real SDKs offer the same shape. - 4
Stopping early. If the consumer
breaks out of the loop, the generator is left suspended. Close it withawait gen.aclose()(or wrap it incontextlib.aclosing(...)) so itsfinallyblocks and any open connection are cleaned up. A generator that is just dropped is cleaned up later by the event loop, at a time you do not control. - 5
**
async with** works likewith(Topic 7.4), but entering and leaving can await:__aenter__and__aexit__are coroutines. Use it for things that open and close over the network: a database connection, an HTTP client, a lock, a semaphore, aTaskGroup, a timeout. You can write one as a class with the two methods, or as a generator decorated with@contextlib.asynccontextmanager. ReturningTruefrom__aexit__hides an exception; returnFalse(or nothing) to let it through. - 6
**
asyncio.Queue** is the async version of a thread queue (Topic 12.3):await queue.put(item)waits if the queue is full (back-pressure),await queue.get()waits if it is empty,task_done()marks an item handled, andawait queue.join()waits for all items. A common design is some producer tasks, a queue, and a few worker tasks; the queue's size limit stops fast producers from overloading slow consumers. Async comprehensions ([x async for x in gen]) collect a whole stream into a list.
Explain it without notes
What is the difference between a normal generator and an async generator? When do you need the async one?
What does async with do that with cannot, and how can you write your own async context manager?
Why is closing an async generator after break important in streaming code?
How does a bounded asyncio.Queue create back-pressure, and why is task_done called in finally?
You have two async streams (say two model endpoints) and want to handle items from whichever produces first. A plain async for over one then the other does not do that. How do you do it?
Practice
Write an async generator countdown(n) that yields n, n-1, ... 1 with await asyncio.sleep(0.01) before each, and print the values joined by spaces.
Write an async generator take(stream, k) that yields at most the first k items of another async stream. Use it on countdown(10) with k=3.
Write an async context manager timer_label(name) with @asynccontextmanager that prints start <name> and end <name> around a block.
Trade-offs
- ↔
Streaming vs waiting for the whole result: streaming shows the first piece sooner and uses less memory, but your code must handle partial results and errors in the middle of a stream.
- ↔
Bounded vs unbounded queue: a bound protects memory and gives back-pressure, but can slow producers and causes deadlock if a consumer never takes items.
- ↔
Class vs @asynccontextmanager: the decorator is shorter; the class is clearer when you need state or want to control exactly what
__aexit__does with exceptions.
Done when you can
Done when you can write an async generator and consume it with
async for.Done when you can explain why a streaming LLM response is an async iterator.
Done when you can stop a stream early and close it properly.
Done when you can write an
async withmanager in both forms.Done when you can build a producer, a bounded queue and workers, and shut them down cleanly.