You write async def and suddenly every function becomes a coroutine. But async is not free. CPU-bound work still starves the event loop, and type annotations collapse to Any without the right protocol types.
Think of async like a restaurant kitchen with one chef. When every order is quick prep plus waiting for ingredients, the chef handles dozens of tables. But when one order requires 30 minutes of continuous stove time, every other table waits. That is the limit of cooperative concurrency: a single long CPU task blocks everyone.
Async Python is a concurrency model for waiting workloads.
- it helps when work spends time waiting for sockets, timers, subprocesses, or other external readiness
- CPU-bound Python bytecode still runs exclusively inside one event-loop thread
- it needs accurate protocol types if you want APIs to stay clear under maintenance
# [CURRENT - 3.10-3.14] Works on Python 3.10+from collections.abc import AsyncIteratorimport asyncioasync def ticker() -> AsyncIterator[int]: for value in (1, 2, 3): await asyncio.sleep(0.1) yield valueAsync code is valuable when each unit of work naturally alternates between:
- brief CPU work
- an
awaiton external readiness
That gives the event loop scheduling opportunities between tasks. If those await points are frequent and honest, one thread can keep many I/O-bound operations in flight.
If a coroutine does long CPU work without awaiting, that advantage disappears.
# [OLDER / 3.7-3.8, CURRENT - 3.10-3.14] Works on Python 3.7+async def cpu_heavy(): total = 0 for i in range(50_000_000): total += i return totalThis function is legal async code, but operationally it is a bad citizen. Once it starts running, the loop cannot preempt it at Python level. Other tasks wait until the function either returns or reaches an await, and here it never does.
That is the important engineering boundary:
- async improves overlap for waiting workloads
- process-based CPU parallelism requires
multiprocessingor similar - a blocking library behind async syntax still freezes the loop
The event loop is cooperative. It resumes a task, the task runs Python bytecode, and the loop gets control back only when the task:
- awaits another awaitable
- returns
- raises
There is no general "timeslice the coroutine after N Python instructions" contract at the language level. That is why event-loop starvation is easy to create with:
- CPU-heavy loops
- synchronous compression or parsing
- blocking file or network APIs
- accidental
time.sleep(...)insideasync def
# [CURRENT - 3.10-3.14] Works on Python 3.ximport asyncioimport timeasync def frozen(): time.sleep(0.5) return "done"The presence of async def does not make time.sleep cooperative. The coroutine simply blocks the event-loop thread.
See for the base scheduling model and for practical offloading boundaries such as asyncio.to_thread.
The async side of the type system is about protocol clarity, not decoration.
The main interfaces are:
Awaitable[T]: something you canawaitto getTCoroutine[Any, Any, T]: a coroutine object whose final result isTAsyncIterable[T]: something usable inasync forAsyncIterator[T]: an async iterable that also provides the next-item protocolAsyncGenerator[T, SendT]: an async generator function result
For modern Python, prefer the abstract protocol type that matches the boundary you are exposing.
# [CURRENT - 3.10-3.14] Works on Python 3.10+from collections.abc import AsyncIterableasync def consume_ids(source: AsyncIterable[int]) -> list[int]: return [value async for value in source]This signature is stronger than source: object and more reusable than forcing callers to hand you one concrete async generator implementation.
When the function specifically manufactures and returns a native coroutine object, the most useful public annotation is usually still the final awaited result type on the function itself:
# [CURRENT - 3.10-3.14] Works on Python 3.10+import asyncioasync def fetch_total() -> int: await asyncio.sleep(0.1) return 3Callers see async def ... -> int because the annotation describes the value produced when awaited. If you are typing a variable that stores the created coroutine object, that is when Coroutine[..., T] or Awaitable[T] becomes useful.
# [CURRENT - 3.10-3.14] Works on Python 3.10+from collections.abc import Awaitableasync def fetch_total() -> int: return 3pending: Awaitable[int] = fetch_total()pending.close() # native coroutine object in this exampleThis connects directly to : accept the most general correct input protocol, and return the type your caller actually needs.
Do not promise more than the runtime model actually provides.
Bad assumptions include:
- "async means parallel"
- "async means non-blocking by default"
- "an async iterator is automatically buffered"
- "a typed awaitable is safe to forget about"
Typed async APIs still need lifecycle discipline:
- awaited completion
- timeout policy
- cancellation policy
- ownership of spawned tasks
That is why structured concurrency is separate from basic async syntax. For failure boundaries and task ownership, use .
Current project guidance targets Python 3.10-3.14. Python 3.9 and below are End-of-Life.
Relevant version markers:
- native coroutines and
await: Python 3.5+ PEP 492 - async generators: Python 3.6+ PEP 525
- async comprehensions: Python 3.6+ PEP 530
collections.abcgeneric aliases such asAsyncIterator[int]: standard modern spelling in current supported Python
For this repo's supported range, these async protocol types are stable baseline tools. Older typing-module spellings remain historically relevant, but they are not the best default for new code unless compatibility constraints force them.
sys.getswitchinterval() and the thread scheduler do not solve coroutine starvation inside one event loop. Thread scheduling is a different mechanism from async task cooperation.
Annotating everything as Coroutine[Any, Any, Any] leaks implementation detail and makes public APIs harder to use. Most callers care that they can await a result, iterate an async stream, or pass an async source into your function.
Async generators are not general-purpose containers. They are one-pass streams with awaited production boundaries.
If the workload is CPU-bound, your first async design question should be whether the work belongs in another process, native extension, or service boundary. Pretending the event loop will absorb it is operationally dishonest.
Use async when:
- throughput depends on overlapping waits
- you need many concurrent sockets or timers
- the system is mostly I/O-bound and cancellation-aware
Do not use async as a reflex when:
- the hot path is CPU-heavy Python code
- the dependency stack is fundamentally blocking
- the team cannot maintain task ownership and timeout discipline
Type async APIs with protocol intent:
Awaitable[T]when the boundary is "I can await this"AsyncIterable[T]when the boundary is "I can consume this stream"AsyncIterator[T]when the consumer pulls values step by step- concrete return annotations on
async deffor the final awaited result