Typing Coroutine Functions: Awaitable vs Coroutine¶
An async def function is not typed as returning its declared value. It returns a coroutine object, and callers that store, pass around or schedule async functions have to say which awaitable shape they accept. Two choices dominate — Awaitable[T] and Coroutine[Any, Any, T] — and the wrong one shows up in the first asyncio.create_task call. Checked with mypy 2.4.0 in --strict mode and pyright 1.1.414 on Python 3.14: a parameter typed Callable[[int], Awaitable[str]] accepted an async function and rejected a plain def returning str in both checkers; passing its result to create_task failed in both, because create_task requires a coroutine; and assigning fetch(2) to a str variable was reported as a missing await by mypy and as an incompatible CoroutineType by pyright. This guide picks the right annotation for each place an async callable appears.
Prerequisites¶
- Python 3.11+,
pip install mypy pyright(tested with mypy 2.4.0 and pyright 1.1.414). - Coroutines, tasks and futures, from Asyncio Fundamentals & Event Loop Architecture.
- The topic overview, Typing Async Code.
1. Read what the checker thinks an async function is¶
Ask both checkers to reveal the type of an async function and of a call to it:
import asyncio
from typing import reveal_type
async def fetch(n: int) -> str:
await asyncio.sleep(0)
return str(n)
reveal_type(fetch) # mypy: def (n: int) -> typing.Coroutine[Any, Any, str]
# pyright: (n: int) -> CoroutineType[Any, Any, str]
reveal_type(fetch(1)) # mypy: typing.Coroutine[Any, Any, str]
# pyright: CoroutineType[Any, Any, str]
The declared return type str is what await fetch(1) produces; the call itself produces a coroutine parameterized by three types: what it yields to the event loop, what can be sent into it, and what it returns. The first two are Any for every async def, which is why annotations for async callables only ever vary the third. Pyright reports the concrete types.CoroutineType; mypy reports the abstract typing.Coroutine. Both accept either spelling in annotations.
Verify: reveal_type on one of your own async functions shows Coroutine[Any, Any, <declared return>].
2. Accept Awaitable when you only await¶
A function that takes an async callable and only awaits what it returns should accept the widest type, so callers may pass a coroutine function, a function returning a Task, or a custom awaitable:
from collections.abc import Awaitable, Callable
async def run_awaitable(make: Callable[[int], Awaitable[str]]) -> str:
return await make(1)
def not_async(n: int) -> str:
return str(n)
async def main() -> None:
await run_awaitable(fetch) # fine
await run_awaitable(not_async) # rejected by both checkers
Both checkers rejected the synchronous callback. mypy reported Argument 1 to "run_awaitable" has incompatible type "Callable[[int], str]"; expected "Callable[[int], Awaitable[str]]"; pyright added the reason, "__await__" is not present. Without the annotation that mistake surfaces only at runtime, as TypeError: 'str' object can't be awaited, on the first call that reaches that line.
Verify: passing a synchronous function where your API expects an async one is a type error, not a runtime surprise.
3. Require Coroutine when you schedule¶
The moment the function hands the result to asyncio.create_task, TaskGroup.create_task or loop.run_until_complete with a coroutine-only argument, Awaitable is too wide:
from typing import Any
from collections.abc import Coroutine
async def run_task(make: Callable[[int], Awaitable[str]]) -> str:
task = asyncio.create_task(make(1)) # error in mypy and pyright
return await task
async def run_task_ok(make: Callable[[int], Coroutine[Any, Any, str]]) -> str:
task = asyncio.create_task(make(1)) # Task[str]
return await task
mypy reported Argument 1 to "create_task" has incompatible type "Awaitable[str]"; expected "Coroutine[Any, Any, Never]", followed by two knock-on errors (Need type annotation for "task" and Returning Any). Pyright reported the same root cause against typeshed's _CoroutineLike alias. The checkers are right: at runtime create_task raised TypeError: a coroutine was expected, got <Future finished result=1> when handed a future, so the narrower annotation documents a real requirement. If you want to accept any awaitable and still schedule it, use asyncio.ensure_future, which wraps futures and coroutines alike — and accept that the signature is now about scheduling, as covered in create_task vs ensure_future.
Verify: every create_task call in the codebase receives a value the checker knows is a Coroutine.
4. Let the checker find missing awaits in assignments¶
The commonest async typing bug is not a wrong annotation but a missing await. Declared variable types turn it into an error:
async def main() -> None:
x: str = fetch(2) # forgot await
# mypy: Incompatible types in assignment (expression has type
# "Coroutine[Any, Any, str]", variable has type "str")
# note: Maybe you forgot to use "await"?
# pyright: Type "CoroutineType[Any, Any, str]" is not assignable to declared type "str"
Without the annotation, x would be inferred as a coroutine and the error would surface wherever x is used as a string — or nowhere, if it is only logged. mypy's hint names the fix directly. A statement that calls a coroutine function and discards the result is caught even without annotations, by mypy's default unused-coroutine check and pyright's reportUnusedCoroutine; the cases neither catches are in catching missing awaits with mypy and pyright.
Verify: removing an await from any annotated assignment in your code produces a type error.
5. Type stored callables and registries¶
Callback registries, plugin tables and handler maps store async callables for later. Give the stored type the same thought:
from collections.abc import Awaitable, Callable
Handler = Callable[[dict[str, object]], Awaitable[None]]
HANDLERS: dict[str, Handler] = {}
def register(event: str) -> Callable[[Handler], Handler]:
def deco(fn: Handler) -> Handler:
HANDLERS[event] = fn
return fn
return deco
@register("user.created")
async def on_user_created(payload: dict[str, object]) -> None:
...
async def dispatch(event: str, payload: dict[str, object]) -> None:
await HANDLERS[event](payload) # only awaited: Awaitable is right
A type alias keeps the registry, the decorator and every handler in agreement, and the checker rejects a handler with the wrong payload type or a synchronous body at the point of registration. If dispatch later changes to run handlers as background tasks, change the alias to Coroutine[Any, Any, None] and let the checker find every handler that no longer fits. Decorators that wrap rather than register need ParamSpec to keep the wrapped signature, covered in typing async decorators with ParamSpec.
Verify: registering a synchronous function, or one with the wrong argument type, fails type checking at the decorator line.
Verification¶
Coroutine-function annotations are right when:
reveal_typeshowsCoroutine[Any, Any, T]for async functions, and variables holding awaited values are typedT.- Parameters that are only awaited accept
Awaitable[T], and a sync callback is rejected. - Parameters that are scheduled require
Coroutine[Any, Any, T], socreate_tasktype-checks. - Registries use one alias shared by the decorator, the table and the dispatcher.
Diagnostic Hook: run mypy --strict and look for Need type annotation for "task" next to an arg-type error on create_task. That pair almost always means a parameter typed as Awaitable is being scheduled; narrowing it to Coroutine clears both errors at once.
Pitfalls & edge cases¶
Awaitableintocreate_task. Both checkers reject it, correctly:create_taskneeds a coroutine.- Annotating the coroutine instead of the result.
async def f() -> Coroutine[...]is wrong; annotate whatawait f()returns. - Untyped locals. A missing
awaitin an unannotated assignment is only found where the value is misused. CoroutinewhereAwaitablewould do. It rejects callers that pass a function returning aTaskorFuture.
Frequently Asked Questions¶
Should I use Awaitable or Coroutine for async callbacks in Python?
Use Callable[..., Awaitable[T]] when your code only awaits the result, and Callable[..., Coroutine[Any, Any, T]] when it passes the result to asyncio.create_task or a TaskGroup, which require a coroutine. mypy 2.4 and pyright 1.1.414 both rejected an Awaitable passed to create_task.
What type does an async def function return?
Calling it returns Coroutine[Any, Any, T], where T is the declared return type; awaiting that coroutine produces T. mypy reports typing.Coroutine and pyright reports types.CoroutineType.
Why does mypy say Coroutine[Any, Any, Never] for create_task?
The argument was typed too widely, usually as Awaitable[T], so mypy could not match create_task's coroutine parameter and could not infer the task's result type. Narrow the argument to Coroutine[Any, Any, T].
Can a type checker find a missing await?
Often. An annotated assignment of a coroutine to a str variable is an error in both mypy and pyright, and a discarded coroutine call is flagged by default in both; some cases, such as passing a coroutine to print, are not caught.
Related¶
- Typing Async Code — up to the topic overview.
- Typing async decorators with ParamSpec — keeping signatures through wrappers.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.