Async Generators & Comprehensions
Lesson 3 of 5
3Async Generators & Comprehensions
The big pictureWhen data arrives piece by piece with a wait between the pieces: you write a generator that combines yield and await in one body, consume it with async for, shrink that to a one-line async comprehension, annotate it with the right type, and finally understand why four parallel comprehensions cost the time of one.
What the picture shows: The generator produces 1, then waits, then 2, then 3, and the async for loop receives each value as it is ready without stopping the rest of the program.
Map of this part
The concepts in order, click any one to jump to it:
- A quick reminder: the ordinary generator
- The async generator: yield and await in one body
- The four lookalike shapes (the table that settles the confusion)
- async for: the loop that waits between rounds
- The async comprehension
- await inside a comprehension: a different case
- Type annotations for generators
- Closing the generator and cleaning up
- Why do four parallel comprehensions ≈ the time of one?
Two ways into the same idea
A generator is like a tap: it does not give you the whole water tank at once, just a cup each time you ask. An async generator is a tap in a faraway house: you ask for a cup and wait a little for it to arrive, and while you wait you can do something else.
A plain generator uses yield to return a value and keep its state. An async generator is an async def with both yield and await, consumed with async for. An async comprehension [x async for x in gen()] shortens the loop, it is annotated as AsyncIterator[int], and aclose() closes it and cleans up.
Required Fundamentals
the Async Python lesson: async and await
This part is a direct continuation of it. If the event loop and gather are not yet clear to you, go back to the Async Python lesson first.
Comprehensions (the Data structures lesson)
[x for x in items] is the form to which we will add exactly one word: async.
Functions and return (the Functions lesson)
The difference between a function that returns a whole list and one that produces values one at a time is the whole difference between return and yield.
Exceptions (the Exceptions lesson)
The end of an async generator arrives as a StopAsyncIteration exception, which the async for loop catches on your behalf.
Core Concepts
In the previous part the result arrived all at once at the end of the wait. But a lot of real data does not come that way: rows read from a database, pages of results arriving one after another, messages on an open connection. Here you need something that produces values successively with waiting in between, and that is exactly the async generator.
1A quick reminder: the ordinary generator
A function containing yield does not return a value; it returns a generator: an object that produces one value per request and then freezes at that line until the next request.
def counter():
for i in range(3):
yield i
g = counter() # nothing has run yet
print(next(g), next(g)) # 0 1 each call runs up to the next yieldThe big benefit is laziness. A generator walking a gigabyte file keeps one line in memory, while a full list keeps the entire file.
2The async generator: yield and await in one body
Before Python 3.6 combining the two was forbidden. PEP 525 lifted that restriction, so you can now write:
async def menu_stream():
for i in range(5):
await asyncio.sleep(0.2) # a wait that hands the turn back to the loop
yield round(random.uniform(0, 10), 2) # then produce a valueThe function is now not a coroutine despite the async def: the presence of yield changed its kind entirely. Calling it gives you an object of type async_generator, and you may not put await in front of it, you use async for.
3The four lookalike shapes (the table that settles the confusion)
| The shape | Has yield? | Defined async? | Calling it gives | How to consume it |
|---|---|---|---|---|
| Ordinary function | No | No | The value itself | Use it directly |
| Generator | Yes | No | generator | for |
| Coroutine | No | Yes | coroutine | await |
| Async generator | Yes | Yes | async_generator | async for |
inspect.isasyncgenfunction(f) gives True for an async generator, while inspect.iscoroutinefunction(f) gives False for it. We ran it in step 3 and the result is in the guide.
4async for: the loop that waits between rounds
An ordinary for asks for the next item with __next__ and receives it immediately. An async for asks with __anext__, which returns something awaitable, so the loop hands the turn back to the event loop between every two items. And the end of iteration arrives as a StopAsyncIteration exception that the loop catches for you.
And async for, like await, is forbidden outside an async function.
5The async comprehension
PEP 530 added, in Python 3.6, permission to use async for inside comprehensions, so this:
result = []
async for v in menu_stream():
result.append(v)is written like this:
result = [v async for v in menu_stream()]
The idea works in list, set, dict and generator comprehensions, together with an optional condition:
big = [v async for v in menu_stream() if v > 5]
as_set = {v async for v in menu_stream()}
as_dict = {i: v async for i, v in numbered_stream()}async comes before for, not before the comprehension
The correct form is [v async for v in src], not async [v for v in src]. And the whole comprehension stays inside an async function, or you get a SyntaxError.
6await inside a comprehension: a different case
Watch the difference between two forms that are easy to confuse:
| The form | The source | What it does |
|---|---|---|
[v async for v in agen()] | An async generator | Waits between every two items while iterating |
[await f() for f in funcs] | An ordinary list of functions | Waits for each call's result, one after another |
await asyncio.gather(*(f() for f in funcs)) | An ordinary list | Runs them all together |
The middle form is a genuine performance trap: it looks “asynchronous” while being perfectly sequential. If you want concurrency the answer is gather, not a comprehension.
7Type annotations for generators
| What you wrote | The annotation | What the parameters mean |
|---|---|---|
| An ordinary generator | Generator[Y, S, R] | Yield type, type sent in with send, final return type |
| An async generator | AsyncGenerator[Y, S] | Two parameters only: there is no return type, because an async generator returns no value |
| A coroutine | Coroutine[Any, Any, R] | Rarely needed: annotate the final value type directly instead |
from typing import AsyncGenerator
async def count_slowly() -> AsyncGenerator[float, None]:
...typing: from typing import AsyncGenerator, because collections.abc.AsyncGenerator does not accept square brackets before 3.9. The modern documentation points you at collections.abc because it assumes a newer version, so write what suits your version, not what suits the docs.
8Closing the generator and cleaning up
An async generator may be holding a connection or a file. If you do not consume it to the end, close it yourself with await g.aclose(), and put your cleanup in a finally inside the generator so it runs either way. An async for loop that ends naturally handles this for you.
9Why do four parallel comprehensions ≈ the time of one?
This is the most important question in this part, and the answer is two lines:
- Inside one comprehension: ten values, each after a one-second wait, and the waits are sequential because the generator does not produce the second value before the first. The total is ≈ 10 seconds, and there is no way to shorten it.
- Between the four comprehensions: they are unrelated, so when you pass them to
gathertheir four waits overlap completely. The total is ≈ 10 seconds as well, not 40.
In other words, asyncio collapses independent waits and does not collapse sequential ones. We measure exactly this in step 6.
How This Actually Works, Step by Step
Three pictures: how to tell which shape you wrote, what goes on inside an async for, and why the time does not multiply with the number of comprehensions.
Diagram 40, How to know what you wrote. Two questions decide the kind and the way to consume it, and most “object is not iterable” errors come from mixing up two branches of this tree.
Diagram 41, What happens between every two items. The difference from an ordinary for is that this waiting period is not wasted: the loop runs somebody else during it.
Diagram 42, The ten waits inside each comprehension are sequential so they cannot be collapsed, but the four comprehensions are independent so they overlap completely. The total is the time of one of them.
- Write the source as an async generator if the values arrive successively with waiting in between.
- Consume it with
async forif you need logic inside the loop, or with a comprehension if the goal is to collect a list. - Annotate it with
AsyncGenerator[Y, None]imported fromtypingon 3.8. - To run several independent comprehensions together, pass them to
gather; a comprehension alone parallelizes nothing. - Measure with
perf_counterand check the number matches what you expected; if not, understand why before moving on.
Worked Example: the Order Screen That Streams
The coffee shop has a screen that shows orders as they arrive from the till, not in one batch at the end of the day. Every piece of code and every output below was actually executed.
Step 1, The source: an async generator
import asyncio, random, time
random.seed(11)
async def menu_stream():
"""produces 5 orders, 0.2 seconds apart"""
for i in range(5):
await asyncio.sleep(0.2)
yield round(random.uniform(0, 10), 2)Step 2, Consuming it with async for
async def main():
t0 = time.perf_counter()
seen = []
async for v in menu_stream():
seen.append(v)
print("async for ->", seen, "in", round(time.perf_counter()-t0, 2), "s")
asyncio.run(main())
# async for -> [4.52, 5.6, 9.24, 4.66, 5.08] in 1.0 sFive values × 0.2 seconds = one second. The waits inside the generator are sequential by nature.
Step 3, What kind of object is this anyway?
import inspect
async def inspect_it(): # inside an async def because of aclose
g = menu_stream()
print(type(g).__name__) # async_generator
print(inspect.isasyncgenfunction(menu_stream)) # True
print(inspect.iscoroutinefunction(menu_stream)) # False
await g.aclose() # closed by hand since we did not consume it
asyncio.run(inspect_it())The third result is the useful surprise: an async generator is not a coroutine, which is why neither await nor asyncio.run works on it directly.
Step 4, The same work as a one-line comprehension
vals = [v async for v in menu_stream()]
print("async comp ->", vals)
# async comp -> [5.87, 1.85, 5.12, 6.3, 7.93] in 1.0 sSame time and same result, three lines shorter. The comprehension is a shorthand for the loop, not a speed-up of it.
Step 5, With a condition
big = [v async for v in menu_stream() if v > 5]
print("filtered ->", big)
# filtered -> [8.1, 6.93]The condition is applied after each value arrives, exactly as in an ordinary comprehension.
Step 6, Four comprehensions, sequentially then in parallel
async def gen(): # 10 values, 0.1s apart -> one second per run
for _ in range(10):
await asyncio.sleep(0.1)
yield random.uniform(0, 10)
async def comprehension():
return [v async for v in gen()]
async def four_sequential():
t0 = time.perf_counter()
for _ in range(4):
await comprehension() # we wait for each before the next
return round(time.perf_counter() - t0, 2)
async def four_parallel():
t0 = time.perf_counter()
await asyncio.gather(*(comprehension() for _ in range(4)))
return round(time.perf_counter() - t0, 2)
print("4 x sequential:", asyncio.run(four_sequential()), "s") # 4.02 s
print("4 x gather :", asyncio.run(four_parallel()), "s") # 1.0 sStep 7, The “looks concurrent” comprehension trap
jobs = [comprehension for _ in range(4)] # inside an async def slow = [await f() for f in jobs] # 4.02 s, the same number as "sequential" above fast = await asyncio.gather(*(f() for f in jobs)) # 1.0 s, the same number as gather above
The two lines look similar and differ by a factor of four, the same difference we measured in step 6, because this comprehension is nothing but another way of writing the sequential loop. An await inside a comprehension means “wait for this one, then move on”, and that is sequence, not concurrency.
Step 8, Measuring time inside a coroutine
async def time_four_runs():
t0 = time.perf_counter()
await asyncio.gather(*(comprehension() for _ in range(4)))
return time.perf_counter() - t0Note the difference from the Async Python lesson: here the function is a coroutine because it awaits gather inside itself, whereas there it was an ordinary function because it called asyncio.run. The rule: whoever awaits something is a coroutine, and whoever opens the loop is an ordinary function.
Myth vs. Reality
| The myth | The reality |
|---|---|
“Any function with async def is a coroutine.” | A yield in its body turns it into an async generator, whose type is async_generator, not coroutine. We proved it with inspect in step 3. |
| “An async comprehension fetches the values in parallel.” | It fetches them successively, exactly like the loop. It is a syntax shorthand; parallelism comes only from gather. |
“[await f() for f in funcs] is async code, so it is fast.” | It is entirely sequential: it waits for each call before the next. Concurrency needs gather, and we measured the difference: four times. |
“I can write await in front of an async generator.” | No. It is not awaitable. You consume it with async for or an async comprehension. |
“AsyncGenerator takes three parameters like Generator.” | Two only, [YieldType, SendType], because an async generator returns no final value. |
| “Async generators work on any Python version.” | From 3.6 only (PEP 525), and async comprehensions from 3.6 as well (PEP 530), after async/await arrived in 3.5 (PEP 492). |
Where Is This Used in Practice?
Paginated results
An API that returns 100 results per request: an async generator fetches a page and yields its items, so your code deals with a single stream instead of juggling page numbers.
Database rows (the Python and databases lesson)
Asynchronous drivers expose results as a stream, so you process a million rows in constant memory instead of loading them all.
Open connections
WebSocket or Server-Sent Events: messages arrive whenever they arrive, and async for is the natural way to receive them.
Streaming model responses
Language models send the reply token by token; you consume it with async for and display it as it arrives instead of waiting for completion.
Large files
Asynchronous line-by-line reading keeps memory flat and leaves the loop free to serve other requests between lines.
Tests
A fake async generator simulates a slow source with no real network, so you test the consuming logic on its own.
Try it yourself
What reads the values of an async def function that contains yield?
An async generator is read with async for or an async comprehension. A plain for and an await in front of it are both errors.
Quick Review Flashcards
Check Your Understanding
1. You wrote async def f() containing a yield, then called it with await f() and got an error. Why?
Because the yield made it an async generator rather than a coroutine, and a generator is not awaitable. Consume it with async for v in f(), or with the comprehension [v async for v in f()].
2. What is the difference in time between [v async for v in gen()] and [await f() for f in funcs]?
The first walks a single source whose values arrive successively, so its time is the sum of waits that are sequential by nature. The second calls independent functions one after another, so its time is the sum of their times even though they could have run together with gather. The first has no alternative; the second is a missed opportunity.
3. (Spot the bug) An async generator opens a connection then yields rows. The code breaks out of the loop after ten rows, and connections pile up open. What is the fix?
Leaving with break leaves the generator paused at the yield, so it never reaches the closing code. Put the close in a finally inside the generator, and call await g.aclose() explicitly if you hold the generator in a variable. Consuming to the end closes it automatically, but a break is not an end.
4. Why does AsyncGenerator take two parameters while Generator takes three?
The third parameter of Generator is the type of the final return value delivered in StopIteration. An async generator is not allowed to return a value with return, so that parameter has nothing to describe.
5. (Decision) You want only the first ten items of a long stream. Comprehension or loop?
A loop with a break after the tenth, because a comprehension consumes the stream to its end and has no stopping condition. If you insist on the comprehension, limit the source itself. And most importantly: close the generator after the break, as in question 3.
Resources for this part
Sorted by kind: start with the official documentation for the precise reference, or with an article or video if you want the explanation first.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Official docs3
Done with this lesson?
Sign in to save your progress.