Async code still has limits: CPU work blocks the loop, blocking libraries block the loop, and imprecise typing can erase the boundaries that matter.
Core answer
Use async for waiting-heavy concurrency. Offload blocking synchronous work deliberately. Type async APIs by the protocol callers consume: Awaitable, AsyncIterable, AsyncIterator, or concrete coroutine-returning functions.
# [CURRENT - 3.10-3.14] Works on Python 3.10+import asynciofrom dataclasses import dataclassfrom collections.abc import Awaitable@dataclass(frozen=True, slots=True)class Parsed: order_id: strasync def load_order(order_id: str) -> Parsed: await asyncio.sleep(0.01) return Parsed(order_id)async def await_one(pending: Awaitable[Parsed]) -> Parsed: return await pendingprint(asyncio.run(await_one(load_order("ORD-7"))))Why this design exists
Asyncio is cooperative. That model is efficient for waits because suspension is explicit. It is not preemptive CPU scheduling. Precise async types keep that distinction visible to callers and static tools.
Mechanics and CPython internals
A coroutine runs Python bytecode until it awaits an incomplete awaitable or finishes. Pure Python CPU loops do not yield just because they live inside async def. asyncio.to_thread can protect the event loop from blocking calls, while regular CPython still serializes Python bytecode behind the GIL across threads in one interpreter.
# [CURRENT - 3.10-3.14] Works on Python 3.10+import asynciofrom dataclasses import dataclass@dataclass(frozen=True, slots=True)class DigestWork: size: intdef crunch(work: DigestWork) -> int: total = 0 for value in range(work.size): total += value * value return totalasync def main() -> None: print(await asyncio.to_thread(crunch, DigestWork(20_000)))asyncio.run(main())Complexity and tradeoffs
Offloading preserves loop responsiveness; it does not guarantee CPU speedup for Python code. Type abstractions improve API clarity, but returning the broadest async protocol everywhere can hide ownership: some callers need a reusable async iterable, others need a one-shot coroutine.
Idiomatic patterns and refactoring
Refactor an async def wrapper that blocks the loop into an explicit blocking boundary.
# [CURRENT - 3.10-3.14] Works on Python 3.10+import asyncioimport timefrom dataclasses import dataclass@dataclass(frozen=True, slots=True)class Snapshot: name: strdef load_blocking(name: str) -> Snapshot: time.sleep(0.01) return Snapshot(name)async def load_bad(name: str) -> Snapshot: return load_blocking(name)async def load(name: str) -> Snapshot: return await asyncio.to_thread(load_blocking, name)print(asyncio.run(load("orders")))Common mistakes and edge cases
Do not assume to_thread fixes CPU throughput. Do not annotate every async function as Awaitable[Any] and erase useful result shape. Do not call blocking code inside a timeout and expect cancellation to interrupt arbitrary synchronous work safely.
When to use / When NOT to use
Use typed async boundaries when concurrency model, ownership, and result shape should be visible.
Do not use async to paper over CPU-bound Python algorithms or blocking libraries you have not isolated.