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_doneholds 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 aFuturethe batch resolves later. This is how you turn N round-trips into one, and it cannot be anasync 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-genericsrejects a bareAwaitable.Awaitablewith no parameter meansAwaitable[Any], and anAnyhere silently erases the awaited type through every downstream call. -
--warn-return-anycatchesreturn await something_untyped(). -
--disallow-untyped-decoratorsmatters 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 missingawait. 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.