Skip to content

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

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>].

The awaitable types, from widest to narrowest 3 stacked layers. The awaitable types, from widest to narrowest Awaitable[T] anything with __await__: coroutines, Tasks, Futures, custom objects Coroutine[Any, Any, T] what async def returns; adds send(), throw(), close() Task[T] / Future[T] scheduled on a loop; adds cancel(), done(), result() Accept the widest type you can await; require the narrowest type you must schedule.

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.

Which annotation each use needs A grid of 4 rows by 3 columns. Which annotation each use needs what the code does with it annotate as checked result only awaits the result Callable[..., Awaitable[T]] sync callback rejected passes it to create_task Callable[..., Coroutine[Any, Any, T]] Task[T] inferred stores a scheduled task asyncio.Task[T] .result() is T stores the awaited value T missing await flagged Both checkers reported the same errors for each row; only the wording differed.

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.

Which type should this async callable have? A decision on What happens to the callable's result with 4 outcomes. Which type should this async callable have? What happens to the callable's result? awaited directly Callable[..., Awaitable[T]] widest, most flexible scheduled as a task Callable[..., Coroutine[Any, Any, T]] create_task requires it kept as a running task asyncio.Task[T] cancel, done, result the awaited value T missing await becomes an error Widen for awaiting, narrow for scheduling.

Verification

Coroutine-function annotations are right when:

  • reveal_type shows Coroutine[Any, Any, T] for async functions, and variables holding awaited values are typed T.
  • Parameters that are only awaited accept Awaitable[T], and a sync callback is rejected.
  • Parameters that are scheduled require Coroutine[Any, Any, T], so create_task type-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

  • Awaitable into create_task. Both checkers reject it, correctly: create_task needs a coroutine.
  • Annotating the coroutine instead of the result. async def f() -> Coroutine[...] is wrong; annotate what await f() returns.
  • Untyped locals. A missing await in an unannotated assignment is only found where the value is misused.
  • Coroutine where Awaitable would do. It rejects callers that pass a function returning a Task or Future.

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.