Async & Concurrency
Python's async model in depth -- async/await, the asyncio event loop, tasks vs futures, cancellation, and exception groups
Async & Concurrency
Python's async model is single-threaded and event-loop-based, and it excels at I/O-bound work. This page goes beyond async/await syntax into the actual objects -- coroutines, tasks, and futures -- plus cooperative cancellation, structured concurrency with TaskGroup, and the exception groups that handle many failures at once.
Async/Await
Python's async model is single-threaded, event-loop-based, similar to Node.js. It excels at I/O-bound work:
import asyncio
import httpx
async def fetch_user_data(user_id: str) -> dict:
async with httpx.AsyncClient() as client:
# These run concurrently, not sequentially
profile, orders, preferences = await asyncio.gather(
client.get(f"/api/users/{user_id}/profile"),
client.get(f"/api/users/{user_id}/orders"),
client.get(f"/api/users/{user_id}/preferences"),
)
return {
"profile": profile.json(),
"orders": orders.json(),
"preferences": preferences.json(),
}
# TaskGroup (Python 3.11+) -- structured concurrency
async def fetch_with_error_handling(urls: list[str]) -> list[str]:
results = []
async with asyncio.TaskGroup() as tg:
for url in urls:
tg.create_task(fetch_one(url))
# If any task raises, all others are cancelled
return results
# Semaphore for rate limiting
async def fetch_many(urls: list[str], max_concurrent: int = 10) -> list[str]:
semaphore = asyncio.Semaphore(max_concurrent)
async def fetch_one(url: str) -> str:
async with semaphore:
async with httpx.AsyncClient() as client:
resp = await client.get(url)
return resp.text
return await asyncio.gather(*[fetch_one(url) for url in urls])The asyncio Event Loop Up Close
"It's like Node" is a useful first approximation, but experts know the actual objects. A coroutine is a paused function; a Task wraps a coroutine and schedules it on the loop; a Future is a low-level "result that will arrive". await yields control back to the loop until the awaited thing is ready.
import asyncio
# create_task schedules immediately; the coroutine starts running at the next await.
# Calling a coroutine function WITHOUT awaiting does nothing but emit a warning:
async def main():
coro = work() # not running yet -- just a coroutine object
task = asyncio.create_task(coro) # NOW it's scheduled
await taskCancellation is cooperative: cancelling a task injects a CancelledError at its next await. Never swallow it blindly -- catching Exception accidentally eats cancellation (in 3.8+ CancelledError derives from BaseException precisely to avoid this):
async def worker():
try:
await long_running()
except asyncio.CancelledError:
await cleanup() # do cleanup...
raise # ...then ALWAYS re-raise, or cancellation breaks
# shield() protects a critical section from cancellation propagating inward:
await asyncio.shield(commit_transaction())
# Timeouts (3.11+): the clean, composable way to bound any await
async with asyncio.timeout(5.0):
await slow_operation()Blocking work must leave the loop thread, or it freezes every coroutine. Offload CPU-bound or legacy-blocking calls to an executor:
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, blocking_db_call, arg) # thread pool
# For CPU-bound, pass a ProcessPoolExecutor instead.contextvars is how async code carries per-request state (request id, user, trace id) without threading it through every call -- the async-safe replacement for thread-locals:
from contextvars import ContextVar
request_id: ContextVar[str] = ContextVar("request_id")
request_id.set("abc-123") # each task sees its own value, even concurrentlyException Groups and except*
Structured concurrency raises a new problem: when several tasks fail at once, which exception wins? Python 3.11 answers with ExceptionGroup and the except* syntax, which handles multiple exceptions from a single group:
async def fetch_all(urls: list[str]):
async with asyncio.TaskGroup() as tg: # collects ALL failures, not just the first
for url in urls:
tg.create_task(fetch(url))
# If two tasks raise, the TaskGroup raises an ExceptionGroup containing both.
try:
await fetch_all(urls)
except* TimeoutError as eg: # handles every TimeoutError in the group
log.warning("timeouts: %d", len(eg.exceptions))
except* ConnectionError as eg: # and separately, every ConnectionError
log.error("connection failures: %d", len(eg.exceptions))except* may run more than one of its branches (unlike except, where exactly one wins), because a single group can contain several exception types. You can also build and raise groups yourself for validation that should report all problems at once rather than stopping at the first:
errors = [ValueError(f"row {i}") for i in bad_rows]
if errors:
raise ExceptionGroup("validation failed", errors)add_note() (3.11+) attaches context to any exception without wrapping it -- useful for adding the failing record id as the exception propagates.