skip to content

Why is AsyncIterator[bytes], not Awaitable[bytes], the return annotation for an async generator?

level: middleimportance: should knowfreq 40%

answer

  1. Two protocols, not one
  2. What does calling it actually return?
  3. await once, or async for many times
  4. __aiter__ and __anext__ versus __await__
  5. Awaitable is widest on the consuming side

basics

~20 s

Calling an async def function that contains yield hands back an async generator object, which you drive with async for, never with await. So it is annotated AsyncIterator[bytes]; Awaitable[bytes] describes something awaited once for a single value.

solid answer

~40 s

The two `async def` shapes return different objects. Without `yield`, calling one returns a coroutine object you `await` once, and the annotation names the awaited result — `async def fetch() -> str`, not `-> Awaitable[str]`. With `yield` in the body, calling it returns an **async generator object**: you never await the call, you consume it with `async for`, so the return annotation is `AsyncIterator[bytes]` (or `AsyncGenerator[bytes, SendType]` if callers use `asend()`). `Awaitable[T]` belongs on *parameters* and on functions that hand an awaitable back without awaiting it; it is the widest such annotation, satisfied by a coroutine object, an `asyncio.Task`, an `asyncio.Future` and anything else implementing `__await__`. Annotating an async generator `Awaitable[bytes]` is not a style slip — it describes a completely different protocol.

code

python · 22 lines
python
import asyncio
from collections.abc import AsyncIterator, Awaitable

async def chunks(n: int) -> AsyncIterator[bytes]:
    for i in range(n):
        await asyncio.sleep(0)
        yield b"chunk-%d" % i

async def fetch(name: str) -> str:
    await asyncio.sleep(0)
    return name.upper()

async def run_it(job: Awaitable[str]) -> str:
    return await job

async def main() -> None:
    async for chunk in chunks(2):
        print(chunk)
    print(await run_it(fetch("segment")))
    print(await run_it(asyncio.ensure_future(fetch("task"))))

asyncio.run(main())

go deeper

for a junior

Recall the split: an async def without yield gives a coroutine you await once, and an async def with yield gives an async generator you drive with async for. Annotate the second one AsyncIterator[T].

for a middle

Explain the protocols behind the names — await for awaitables, aiter and anext ending in StopAsyncIteration for async iteration — and why an async generator has no await at all.

for a senior

Show judgement on the consuming side: parameters take Awaitable[T] so tasks and futures qualify, and narrowing to Coroutine is a deliberate choice you make only when the body needs send, throw or close.

for a principal

Own the streaming-versus-request contract across services: whether an API hands back one awaited result or an async stream shapes backpressure, cancellation and error handling far beyond the annotation.

### Two protocols that both start with `async def` Python has two asynchronous protocols and they do not overlap. The **awaitable** protocol is `__await__`. An object implementing it can appear after `await`, which suspends the caller until one result is produced. `collections.abc.Awaitable[T]` is the annotation for "something that, when awaited, gives a `T`". `collections.abc.Coroutine[YieldType, SendType, ReturnType]` is the narrower one describing specifically a coroutine object, which additionally supports `send()`, `throw()` and `close()`. The **async-iteration** protocol is `__aiter__` plus `__anext__`, where `__anext__` returns an awaitable and signals the end by raising `StopAsyncIteration`. `collections.abc.AsyncIterable[T]` promises `__aiter__`; `collections.abc.AsyncIterator[T]` promises both, exactly mirroring the synchronous `Iterable` / `Iterator` split. `async for` drives this protocol, not the awaitable one. ### Which object does the call produce? An `async def` function whose body contains no `yield` is a **coroutine function**; calling it produces a coroutine object, and awaiting that object once produces the value the body returned. The convention is to annotate the awaited result: `async def fetch(name: str) -> str`. A checker infers `Coroutine[Any, Any, str]` at the call site by itself, so writing `-> Awaitable[str]` on the definition is both redundant and wrong — it would mean the function synchronously returns an awaitable. An `async def` whose body contains `yield` is an **async generator function**. Calling it produces an async generator object immediately, without running a line of the body and without any awaiting. There is no result to `await`, so `Awaitable[bytes]` is simply a false description; the accurate one is `AsyncIterator[bytes]`. Use `AsyncGenerator[bytes, ControlType]` only when callers push values in with `asend()`. Note the asymmetry with synchronous generators: `AsyncGenerator` has two parameters, not three, because an async generator cannot `return` a value — that is a syntax error. ### Where Awaitable does belong `Awaitable[T]` is the right annotation on the *consuming* side. A helper that takes work and awaits it should accept `Awaitable[str]`, so callers may pass a coroutine object, a task created from one, a future, or any custom object with `__await__`. Demanding `Coroutine[Any, Any, str]` there would reject `asyncio.Task` and `asyncio.Future`, which are awaitable but are not coroutine objects — a genuine and common bug in shared helper libraries. Narrow to `Coroutine` only if the body really calls `send()`, `throw()` or `close()`, or passes the object somewhere that requires a raw coroutine. The symmetric mistake runs the other way: a plain `def` that builds and returns a coroutine or a task without awaiting it should be annotated `-> Awaitable[str]`, because that is exactly what it hands back. ### A streaming signature in practice A translation-memory updater that streams segments out of storage is naturally `async def segments(...) -> AsyncIterator[bytes]`, consumed with `async for`. Annotate it `Awaitable[bytes]` and every caller who tries to `await` it gets a runtime `TypeError` — the async generator object has no `__await__` — while a caller who correctly writes `async for` is flagged by the checker instead. Both halves of the codebase end up arguing with the annotation rather than with each other. ### Spelling notes Prefer `collections.abc` imports since Python 3.9 (PEP 585); the `typing` names are deprecated aliases. Since Python 3.13, PEP 696 defaults mean `AsyncGenerator[bytes]` is accepted as `AsyncGenerator[bytes, None]`. Contextlib's async helper follows the same pattern: an `async def` with a single `yield` under `contextlib.asynccontextmanager` is itself annotated `AsyncIterator[T]`. ### The consuming side, symmetrically Parameters follow the same widest-honest-promise rule as the synchronous protocols. A helper that only writes `async for chunk in source:` should take `AsyncIterable[bytes]`, because `async for` needs `__aiter__` and nothing more. Take `AsyncIterator[bytes]` when the body drives the stream itself — awaiting `__anext__` explicitly, or pulling a header off the front and passing the remainder along — since that annotation also warns callers the argument will be consumed. And an async generator is single-pass exactly as a synchronous one is: two `async for` loops over the same object give you the items once and then nothing. ### Naming the object a plain def returns The mirror-image case is easy to get backwards. A synchronous `def` that creates work without awaiting it — building a coroutine object, wrapping it in a task, returning a future from a client — genuinely does return an awaitable, so `-> Awaitable[str]` is correct there. The test is mechanical: ask what the *call expression* evaluates to. `async def f() -> str` means `await f()` is a `str`; `def g() -> Awaitable[str]` means `g()` is a thing you still have to await. Getting this backwards produces the classic runtime error where a coroutine object is used as if it were its result, and a checker with correct annotations catches it immediately. ### Why the distinction bites in production Streaming and request-response APIs fail differently. An awaited call either produces a value or raises; an async stream can end early, be abandoned mid-iteration, or need explicit `aclose()` so its `finally` blocks run. Annotating a stream as an awaitable hides that entire class of concern from every reader of the signature, which is a documentation failure long before it is a type-checking one.

  • When should a parameter be Awaitable[str] rather than Coroutine[Any, Any, str]?
    Almost always. `Awaitable[str]` is satisfied by a coroutine object, an `asyncio.Task`, an `asyncio.Future` and any object with `__await__`, so a helper that merely awaits its argument should demand no more. `Coroutine[Any, Any, str]` is correct only when the body actually uses `send()`, `throw()` or `close()`, or hands the object to something that requires a raw coroutine — and it silently rejects tasks.
  • How do you annotate an `async def` function that returns a value?
    Annotate the awaited result: `async def fetch(name: str) -> str`. A type checker knows the call site yields a coroutine object and infers `Coroutine[Any, Any, str]` for it. Writing `-> Awaitable[str]` on an `async def` claims the function synchronously hands back an awaitable, which is a different contract and would be right only for a plain `def`.
  • What distinguishes AsyncIterable[T] from AsyncIterator[T] in a signature?
    `AsyncIterable[T]` promises only `__aiter__`, which is all `async for` needs, so it is the wider parameter annotation. `AsyncIterator[T]` promises `__anext__` as well: it is the cursor itself, it is consumed as it is read, and it ends by raising `StopAsyncIteration`. The split mirrors `Iterable` versus `Iterator` on the synchronous side.

Awaiting is collecting one parcel from the counter; async iterating is standing at a conveyor belt that hands you parcels until it stops.

saying these in an interview costs you the question

  • Writes -> Awaitable[str] on an async def that returns str
  • Says you await the call to an async generator function
  • Treats AsyncIterator and Awaitable as interchangeable
  • Claims async for works on any awaitable object
  • Thinks an async generator can return a value
  • Demands Coroutine where a Task would also be awaited

context