Command Palette

Search for a command to run...

Hectal
PHASE 12Advanced ~29 min· topic 8 of 9

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 def function with yield that produces values over time.
async for
A loop that awaits each next value of an async iterator.
async with
A with block 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.

main.pywhole filepython
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)
An async generator is a streamdiagram
Rendering diagram…

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.

Queues and workersdiagram
Rendering diagram…

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. 1

    Break at a different token

    In the first example change "three" to "two" and predict what stopped after: prints. Then remove await stream.aclose() and run again. The output looks the same; the difference is in cleanup that you do not see. Add a try/finally with a print inside stream_tokens to see when cleanup runs.

  2. 2

    Change the queue size

    In the queue example use Queue(maxsize=1), then Queue() (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. 3

    Hide an exception

    In the Connection example change return False to return True in __aexit__. The ValueError is now swallowed and the last print never runs. Hiding errors is almost always a bug; do it only on purpose.

Code & diagrams

A fake model streams tokens New tab

Tokens are printed as they arrive. Change the text, or break out of the loop earlier.

Sign in to run this example in your browser.

Expected output

Hello from a tiny fake model
tokens received: 6
stopped after: three
collected: ['a', 'b', 'c']
async with: a class and a generator New tab

Watch the order of open, work and close. The third block shows that close still runs when the body fails.

Sign in to run this example in your browser.

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 nonsense
Producer, queue and workers New tab

The 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.

Sign in to run this example in your browser.

Expected output

['prompt 0 -> done', 'prompt 1 -> done', 'prompt 2 -> done', 'prompt 3 -> done', 'prompt 4 -> done', 'prompt 5 -> done']
queue empty: True

Break 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.

main.pywhole filepython
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())
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 15, in <module>
asyncio.run(main())
^^^^^^^^^^^^
File "main.py", line 11, in main
for token in tokens():
~~~~~~^^
TypeError: 'async_generator' object is not iterable

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 throws GeneratorExit at the paused yield. If the generator then awaits inside finally, that is allowed, but yielding inside finally after GeneratorExit raises RuntimeError. contextlib.aclosing(gen) gives a safe async with wrapper.

  • ▸

    Prefer TaskGroup over unmanaged workers when you can. The queue-and-workers example cancels the workers by hand; a version with async 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() and anext() (await anext(gen) takes one item, and await anext(gen, default) returns a default at the end instead of raising StopAsyncIteration). Async generators also have asend, athrow and aclose. Because an async generator runs only when something awaits its next item, **two streams cannot be merged with a plain async for**: run each in its own task and push items into one asyncio.Queue.

  • ▸

    Cancellation reaches an async generator at its current await, and its finally runs as usual. The event loop registers hooks so that unfinished async generators are closed at shutdown (loop.shutdown_asyncgens(), which asyncio.run calls), but a generator that was dropped earlier is only finalised when it is garbage-collected, on the loop's schedule, which is why explicit aclose() or aclosing is the dependable choice.

Remember this

  1. 1

    A normal for loop calls next() 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: calls await source.__anext__() each round and stops when StopAsyncIteration is raised. It works only inside async def.

  2. 2

    An async generator is the easy way to build one: an async def function that contains yield. Each yield hands one value to the async for loop, and the function can await between yields. It is the async version of the generators in Topic 9.2. Unlike a coroutine, calling it does not need await: it returns an async generator object you loop over with async for.

  3. 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 with asyncio.sleep; real SDKs offer the same shape.

  4. 4

    Stopping early. If the consumer breaks out of the loop, the generator is left suspended. Close it with await gen.aclose() (or wrap it in contextlib.aclosing(...)) so its finally blocks 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. 5

    **async with** works like with (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, a TaskGroup, a timeout. You can write one as a class with the two methods, or as a generator decorated with @contextlib.asynccontextmanager. Returning True from __aexit__ hides an exception; return False (or nothing) to let it through.

  6. 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, and await 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

01

What is the difference between a normal generator and an async generator? When do you need the async one?

02

What does async with do that with cannot, and how can you write your own async context manager?

03

Why is closing an async generator after break important in streaming code?

04

How does a bounded asyncio.Queue create back-pressure, and why is task_done called in finally?

05

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

01

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.

02

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.

03

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 with manager in both forms.

  • Done when you can build a producer, a bounded queue and workers, and shut them down cleanly.