async def creates coroutine functions. Calling one creates a coroutine object. Scheduling or awaiting that object is what gives it execution time.
Core answer
Use async code when the work can suspend at explicit await boundaries while other work remains useful. Keep blocking synchronous calls off the event-loop thread.
# [CURRENT - 3.10-3.14] Works on Python 3.10+ [PEP 492]import asynciofrom dataclasses import dataclass@dataclass(frozen=True, slots=True)class Quote: symbol: str cents: intasync def fetch_quote(symbol: str) -> Quote: await asyncio.sleep(0.01) return Quote(symbol, 4200)async def main() -> None: quotes = await asyncio.gather(fetch_quote("PY"), fetch_quote("CPY")) print(quotes)asyncio.run(main())Why this design exists
PEP 492 made native coroutines and await explicit so asynchronous control flow stops pretending to be ordinary generator control flow. The event loop gets cooperation points it can reason about; the code gets visible suspension points reviewers can audit.
Mechanics and CPython internals
An awaitable can be a coroutine or another object exposing the await protocol. CPython resumes coroutine frames until they return, raise, or suspend on an awaitable that is not ready. The GIL still exists in ordinary CPython builds; async concurrency is scheduling within threads, not automatic CPU parallelism.
# [CURRENT - 3.10-3.14] Works on Python 3.10+import asynciofrom dataclasses import dataclassfrom inspect import isawaitable@dataclass(frozen=True, slots=True)class Receipt: order_id: strasync def create_receipt(order_id: str) -> Receipt: await asyncio.sleep(0) return Receipt(order_id)async def main() -> None: coroutine = create_receipt("ORD-7") print(isawaitable(coroutine)) print(await coroutine)asyncio.run(main())Complexity and tradeoffs
Async reduces retained thread cost for many waiting operations, but every task still holds state and every suspension has scheduler overhead. It improves latency overlap for I/O; it does not make CPU-bound Python loops stop blocking the loop.
Idiomatic patterns and refactoring
Refactor blocking sleeps or clients at the loop boundary before scaling fan-out.
# [CURRENT - 3.10-3.14] Works on Python 3.10+import asyncioimport timefrom dataclasses import dataclass@dataclass(frozen=True, slots=True)class Probe: name: strasync def blocks(probe: Probe) -> str: time.sleep(0.01) return probe.nameasync def cooperates(probe: Probe) -> str: await asyncio.sleep(0.01) return probe.nameasync def main() -> None: print(await cooperates(Probe("ready"))) print(await blocks(Probe("blocked")))asyncio.run(main())Common mistakes and edge cases
Do not call a coroutine and forget to await or schedule it. Do not assume an await exists merely because a function name starts with async. Do not run synchronous I/O or large CPU loops inside a coroutine and expect unrelated tasks to progress.
When to use / When NOT to use
Use async for high-concurrency I/O and APIs that already expose awaitable operations.
Do not use async as a style preference around sequential CPU work or around libraries that force blocking calls without an offload boundary.