Async iteration is for values that arrive over awaitable boundaries one at a time. It is streaming concurrency, not list construction with a different keyword.
Core answer
Use async generators when producing each next item may await I/O and consumers should process incrementally.
# [CURRENT - 3.10-3.14] Works on Python 3.10+ [PEP 525]import asynciofrom dataclasses import dataclassfrom collections.abc import AsyncIterator@dataclass(frozen=True, slots=True)class Page: number: int rows: tuple[str, ...]async def pages() -> AsyncIterator[Page]: for number in range(3): await asyncio.sleep(0.01) yield Page(number, (f"row-{number}",))async def main() -> None: async for page in pages(): print(page)asyncio.run(main())Why this design exists
PEP 525 brought generator ergonomics to asynchronous production. PEP 530 extended comprehensions where async iteration genuinely feeds a materialized result.
Mechanics and CPython internals
Async iterables expose __aiter__; async iterators expose awaitable __anext__. Async generator frames suspend at yield and await boundaries, preserving local state between pulls. Each async for asks for one next item and handles StopAsyncIteration when the stream ends.
# [CURRENT - 3.10-3.14] Works on Python 3.10+import asynciofrom dataclasses import dataclassfrom collections.abc import AsyncIterator@dataclass(frozen=True, slots=True)class Event: sequence: intclass EventFeed: def __init__(self, stop: int) -> None: self.current = 0 self.stop = stop def __aiter__(self) -> AsyncIterator[Event]: return self async def __anext__(self) -> Event: if self.current >= self.stop: raise StopAsyncIteration await asyncio.sleep(0) self.current += 1 return Event(self.current)async def main() -> None: print([event async for event in EventFeed(3)])asyncio.run(main())Complexity and tradeoffs
Streaming can keep retained results small, but materializing an async comprehension still consumes O(n) output space. Per-item awaits improve responsiveness and backpressure; they can also add scheduler overhead if the work is tiny and already in memory.
Idiomatic patterns and refactoring
Refactor "fetch all then process" into async iteration when downstream can consume pages incrementally.
# [CURRENT - 3.10-3.14] Works on Python 3.10+import asynciofrom dataclasses import dataclassfrom collections.abc import AsyncIterator@dataclass(frozen=True, slots=True)class Batch: number: intasync def collect_all() -> list[Batch]: return [Batch(number) for number in range(3)]async def stream() -> AsyncIterator[Batch]: for number in range(3): await asyncio.sleep(0) yield Batch(number)async def main() -> None: print(await collect_all()) print([batch async for batch in stream()])asyncio.run(main())Common mistakes and edge cases
Do not expect an async generator to run until it is iterated. Do not hide fan-out inside a generator without bounding it. Close generators or use scoped consumers when cleanup matters.
When to use / When NOT to use
Use async iterators for paginated APIs, streaming reads, and event feeds with awaitable next-item work.
Do not use them for an in-memory list just to make code look asynchronous.