#Task boundary
- Implement only what the task names; no extra abstractions or files.
- English-only comments and identifiers.
- Stop when the checklist passes.
#Purpose
Rules for Python 3.12+ asyncio code. Async in Python is cooperative: one blocking
call stalls every coroutine on the loop, and nothing warns you. Most "async is
slow" reports are a sync call hiding inside an async def.
Framework specifics are Backend/fastapi; ORM async is Database/sqlalchemy.
#When to use it
| Use asyncio | Do not |
|---|---|
| Many concurrent network calls (HTTP fan-out, DB pools, websockets) | CPU-bound work (parsing, hashing, ML) |
| Long-lived connections you must hold cheaply | A script that makes three sequential requests |
| Your framework and drivers are already async | The rest of the codebase and its libraries are sync |
Threads or multiprocessing beat asyncio for CPU; sync code beats it for
simplicity. Choose it for I/O concurrency, and then go all the way — a half-async
codebase gets the costs of both.
#Never block the loop
pythonasync def handler():
data = requests.get(url).json() # blocks the loop for the whole request
time.sleep(1) # blocks every coroutine for 1s
rows = session.execute(stmt) # sync SQLAlchemy: blocks
async def handler():
async with httpx.AsyncClient() as c:
data = (await c.get(url)).json()
await asyncio.sleep(1)
rows = await asession.execute(stmt)
report = await asyncio.to_thread(render_pdf, rows) # CPU/sync → thread
- Every library call inside
async defmust be either awaited or wrapped inasyncio.to_thread.requests,boto3,psycopg2,open()on a network mount — all blocking. - Enable debug mode in development:
asyncio.run(main(), debug=True)orPYTHONASYNCIODEBUG=1logs any callback that ran longer than 100 ms. This is how you find the hidden sync call. to_threadis bounded by the default executor (min(32, cpus+4) threads). Offloading thousands of calls to it serialises them; that is a sign to use an async driver instead.
#Structured concurrency: TaskGroup
pythonasync with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch_user(uid))
t2 = tg.create_task(fetch_orders(uid))
user, orders = t1.result(), t2.result()
TaskGroup(3.11+) cancels the siblings when one fails and re-raises as anExceptionGroup.gather()by default lets the others keep running after one fails, andgather(return_exceptions=True)hands you exceptions as values you can forget to check.- Fire-and-forget
asyncio.create_task(coro())without keeping a reference: the task can be garbage-collected mid-flight. Keep a reference or use a group. except* ValueError:to handle one member type of anExceptionGroup.
#Cancellation and timeouts
pythonasync with asyncio.timeout(5): # 3.11+; raises TimeoutError
await fetch()
try:
await work()
except asyncio.CancelledError:
await cleanup() # allowed: short, itself awaitable
raise # always re-raise
asyncio.timeout()overwait_for(): it is a context manager, composes, and does not create an extra task.- Every
awaitis a cancellation point. Code that must not be interrupted (commit-then-ack) goes inasyncio.shield()or a finally block — and the finally block must be short, because cancellation can arrive again. - Swallowing
CancelledErrorbreaks shutdown andTaskGroupsemantics. Catch, clean up, re-raise. - Timeouts on every external call, without exception. An unbounded await is a
leaked connection under a network partition. →
Backend/error-handling
#Async-native libraries
| Sync | Async replacement |
|---|---|
requests | httpx.AsyncClient, aiohttp |
psycopg2 | asyncpg, psycopg (v3, async) |
redis (sync client) | redis.asyncio |
open() for large files | aiofiles, or to_thread |
time.sleep | asyncio.sleep |
subprocess.run | asyncio.create_subprocess_exec |
Create one client per process and reuse it; a new httpx.AsyncClient per
request discards the connection pool and pays TLS every time. Close it in the
application's shutdown hook.
#Sync boundaries
pythondef cli_entry() -> None:
asyncio.run(main()) # exactly one asyncio.run per process
# Calling async from sync code that is already inside a running loop:
# you cannot. Refactor the caller to be async, or run in a separate thread.
asyncio.run()once, at the top. Nestedrun()calls raise;get_event_loop()in library code is deprecated behaviour.- Semaphores for concurrency limits:
sem = asyncio.Semaphore(20)around fan-out, or you will open 10,000 connections to a service that allows 100. - Async generators need
async with aclosing(gen)or explicitaclose(); an abandoned one holds its resources until finalised.
#Testing
python# pyproject.toml → [tool.pytest.ini_options] asyncio_mode = "auto"
async def test_timeout_cancels_and_cleans_up(monkeypatch):
async def slow(): await asyncio.sleep(10)
with pytest.raises(TimeoutError):
async with asyncio.timeout(0.01):
await slow()
pytest-asyncioinautomode so everyasync def test_*just runs.- Never
await asyncio.sleep(0.5)to "let things settle" — assert on an event or a future. Real sleeps make the suite slow and still flaky on loaded CI. unittest.mock.AsyncMockfor async dependencies; a plainMockreturns a non-awaitable and the test passes for the wrong reason. →Testing/pytest
#Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
requests/time.sleep/sync ORM inside async def | Stalls every coroutine | Async driver or to_thread |
| asyncio for CPU-bound work | No parallelism, loop blocked | Threads/processes |
gather() for related tasks | Siblings continue after a failure | TaskGroup |
gather(return_exceptions=True) | Exceptions become unchecked values | TaskGroup + except* |
create_task() without a reference | Task can be collected mid-run | Keep it, or a group |
except CancelledError: pass | Shutdown and groups break | Clean up, re-raise |
wait_for() | Extra task, awkward composition | asyncio.timeout() |
| No timeout on an external call | Hangs forever on partition | Timeout everywhere |
New httpx.AsyncClient per request | No pooling, TLS each call | One client, reused |
| Unbounded fan-out | Thousands of connections | Semaphore |
Nested asyncio.run() | RuntimeError | One run at the top |
await asyncio.sleep(x) in tests | Slow and flaky | Await an event |
Mock() for an async dependency | Returns non-awaitable | AsyncMock |
#Checklist
- Async chosen for I/O concurrency, not for CPU work
- No blocking call inside any
async def; debug mode used to find them - Sync work offloaded with
asyncio.to_thread, sparingly - Related tasks run under
TaskGroup; no baregather - Every
create_taskresult is retained -
CancelledErroris cleaned up and re-raised, never swallowed - Every external await has a timeout via
asyncio.timeout() - Non-interruptible sections use
shieldor a shortfinally - Async-native drivers used; clients created once and closed at shutdown
- Fan-out bounded with a
Semaphore - Exactly one
asyncio.run()per process - Tests run in
asyncio_mode = "auto"withAsyncMock; no real sleeps