Skip to content
← All articles

Typing Async Code: Coroutine vs Awaitable vs AsyncIterator

Annotate parameters as Awaitable and returns concretely — and never put `async def` in a Protocol unless you mean to forbid caches, batchers and test doubles forever.

Missing an await is the single most common async bug. It is also exactly the class of bug types catch, and getting the Protocol shape wrong is how a codebase locks itself out of ever catching it.

The three annotations, and what each promises

Annotation Accepts Means
Coroutine[Any, Any, T] only a coroutine object I will run this myself, on a loop I control
Awaitable[T] coroutine, Task, Future, anything with __await__ I will await it
AsyncIterator[T] anything with __anext__ I will async for over it

The rule for a parameter is: Awaitable[T] unless you have a reason. Awaitable is the widest thing you can await, so it accepts a coroutine object, a Task, a Future, and an object whose __await__ you wrote. Narrow it to Coroutine only when you are going to do something a coroutine specifically supports — hand it to asyncio.run, close() it, inspect cr_frame. run_sync in the previous item is exactly that case.

The rule for a return is the opposite: be concrete. async def fetch(...) -> bytes is more useful to your caller than a hand-written -> Awaitable[bytes], because the caller learns the awaited value’s type without a second hop.

Where it becomes irreversible: Protocols

These two are not interchangeable, and choosing wrong is a decision you cannot take back without a breaking change:

class Fetcher(Protocol):
    async def fetch(self, key: str) -> bytes: ...   # A

class Fetcher(Protocol):
    def fetch(self, key: str) -> Awaitable[bytes]: ...   # B

A demands a coroutine function. The implementation’s fetch must itself be async def. B demands only that fetch returns something awaitable — so async def fetch satisfies it (a coroutine object is an Awaitable), and so does a plain def returning a Future, a Task, or a memoised awaitable.

B is strictly more permissive, and the implementations A excludes are the ones you actually want to write later:

  • A cache. def fetch(self, key): return self._done[key] where _done holds resolved futures — no suspension at all when the answer is known.
  • A batcher. def fetch(self, key) registers the key in the next batch and hands back a Future the batch resolves later. This is how you turn N round-trips into one, and it cannot be an async def, because the point is that it returns without awaiting.
  • A test double that returns a pre-resolved future so the test needs no scheduling at all.

Every one of those is a [misc] error against Protocol A. And by the time you want one, A is in your public API.

💡If B accepts strictly more implementations, is there ever a reason to write A? click to reveal

Yes, one: when the caller needs the guarantee that calling fetch performs no work — that it is cheap and side-effect-free until awaited.

A coroutine function is guaranteed lazy: calling it builds a coroutine object and executes not one line of the body. A method returning a Task has already scheduled the work by the time it returns. If your framework calls fetch for a hundred keys and then decides to await only three, the difference is ninety-seven cancelled network calls versus ninety-seven objects that were never started.

So the question to ask is “am I promising the implementer freedom, or promising the caller laziness?” Framework-facing plug-in points usually want B. A protocol where the caller constructs many and awaits few — the sort of thing resolve_all in track 7 consumes — is the case where A earns its restriction.

Note that neither annotation gives you a runtime check. @runtime_checkable + isinstance verifies only that an attribute called fetch exists — not that it is callable, not its signature, not its return type.

The flags that make this bite

  • --disallow-any-generics rejects a bare Awaitable. Awaitable with no parameter means Awaitable[Any], and an Any here silently erases the awaited type through every downstream call.
  • --warn-return-any catches return await something_untyped().
  • --disallow-untyped-decorators matters more in async code than anywhere else, because retry/timeout/instrumentation decorators are where async wrappers live, and an untyped one erases the signature of everything it wraps.
  • unused-awaitable (opt-in, --enable-error-code unused-awaitable) flags an expression of awaitable type used as a statement — the literal missing await. It is not in --strict; turn it on. It is one line of config and it catches the bug this whole item is about.
💡self.refresh() as a statement, where refresh is async def refresh(self) -> None. Nothing raises, nothing is logged, the refresh never happens. Why does --strict not catch it, and what does? click to reveal

--strict does not include unused-awaitable, and without it, an expression statement of type Coroutine[Any, Any, None] is just an expression statement — the same as writing x on a line by itself. mypy has no general rule against discarding a value.

Three things do catch it. --enable-error-code unused-awaitable is the direct answer and the one to configure. At runtime, CPython emits RuntimeWarning: coroutine 'refresh' was never awaited when the object is collected — useful, but it appears at collection time, in whatever unrelated code happens to be running, and most services filter warnings out of production logs. And ruff’s RUF006 catches the adjacent case of a discarded create_task result.

The reason this bug is so persistent is that the sync version of the same line is correct. self.refresh() in sync code does the work. The async version compiles, type-checks under --strict, runs, and does nothing at all.