Overview
asyncio runs many I/O-bound tasks on one thread by switching at await points. It does nothing for CPU-bound work; use processes for that (python-performance). Mark a function async only when it awaits something, and call asyncio.run(main()) once at the program entry point, never inside a running loop or a fastapi handler.
Use TaskGroup for concurrent work
asyncio.TaskGroup (3.11+) owns its tasks. If one fails, it cancels the rest and raises an ExceptionGroup, which you handle with except*. No task outlives the async with block.
async def fetch_all(urls: list[str]) -> list[bytes]:
async with httpx.AsyncClient() as client, asyncio.TaskGroup() as tg:
tasks = [tg.create_task(client.get(u)) for u in urls]
return [t.result().content for t in tasks]asyncio.gather propagates the first exception to the awaiter but leaves the other tasks running, so a failed batch keeps doing work nobody will read. Use gather(..., return_exceptions=True) only when you want every result or error back.
A bare asyncio.create_task(...) returns a task the loop references weakly. Keep a reference (a set or a TaskGroup) or it can be garbage collected mid-run.
Bound waits with asyncio.timeout
Wrap any await that can hang in async with asyncio.timeout(seconds):. It cancels the body and raises TimeoutError, and it composes with TaskGroup.
Never swallow CancelledError
CancelledError derives from BaseException, so except Exception does not catch it, but except BaseException and bare except: do. Catch it only to clean up, then re-raise. Swallowing it breaks TaskGroup and timeout, which rely on cancellation propagating.
async def job() -> None:
try:
await do_work()
except asyncio.CancelledError:
await rollback()
raiseasyncio.shield(coro) keeps the inner coroutine running when the outer task is cancelled; the outer await still raises CancelledError, and you must hold a reference to the shielded task.
Keep blocking calls off the event loop
One blocking call (time.sleep, requests.get, a CPU loop) stalls every coroutine on the loop. Use the async equivalent, or run the sync function in a worker thread. Pass the function itself, not its result.
from pathlib import Path
async def read_file(path: Path) -> bytes:
return await asyncio.to_thread(path.read_bytes)Writing asyncio.to_thread(open(path).read) opens the file on the loop thread first.
Inspect a hung program from outside
Python 3.14 added python -m asyncio ps <PID> and python -m asyncio pstree <PID>, which print the live task table and await tree of a running process without modifying it.
Test with pytest-asyncio
Set asyncio_mode = "auto" in the pytest config so async def tests run without a marker, or keep the default strict mode and mark each test with @pytest.mark.asyncio. Fixtures and parametrization are covered in python-testing.