🐍 Backend with Python · Chapter 9
Async Python: when it wins and when it lies
async def makes waiting cheap, not work fast: one blocking call inside it and your whole server queues behind a single request.
Study this lesson free in the app →
The basics
Async Python lets one thread juggle many tasks that are waiting: on the network, the database, a timer. While one waits, another runs.
The problem it solves: a backend spends most of its life waiting on I/O. One thread per request wastes memory and caps you at the thread count. Async lets a single thread keep thousands of requests in flight.
The mental model: one cook, many pots. The cook (the event loop) never stands watching water boil. At every await a task says "wake me when this is ready" and the cook moves to the next pot. The catch: the cook is alone. If a task chops onions for ten seconds without saying await, every pot waits.
Words you must know 5
- Coroutine
- what calling an
async defgives you. Nothing runs until it is awaited or scheduled. - Event loop
- the single scheduler that runs whichever task is ready.
await- the only place a task can pause and let others run.
- Blocking call
- anything that waits without
await(time.sleep,requests.get, a sync DB driver, heavy CPU work). It freezes the loop. asyncio.gather- run several awaitables concurrently and collect their results in order.
Explained
Three downstream calls of 300 ms each. Awaited one after another: 0.90 s. Through asyncio.gather: 0.30 s. That's the win.
Now put one time.sleep(0.3) inside an async def handler and send ten requests: ~3 s, one after another. Same keyword, opposite result. That's the lie.
Async doesn't make code faster. It makes waiting overlap, and only when every wait is an await.
One cook, twelve pots
A dhaba near Lonavala at lunch rush has one cook and a twelve-burner range.
He puts dal on, sets rice to boil, starts the chai, and while all three bubble he takes the next order. He never stands staring at a pot; he checks whichever one is ready. One cook serves forty tables because cooking is mostly waiting.
Then an order comes for chutney ground by hand on the sil-batta: fifteen minutes of solid grinding. He can't walk away from it. The dal burns, the chai boils over, forty tables wait for one chutney.
Two fixes, both obvious in a kitchen: hand the grinding to a helper (a thread or process), or buy ground chutney that only needs warming (an async library that awaits instead of blocking).
Cook = event loop. Walking away from a pot = await. The sil-batta = a blocking call.
What await actually does
Calling fetch() on an async def doesn't run it. It returns a coroutine object, a paused function. It runs when you await it or wrap it in a task (asyncio.gather, asyncio.create_task).
When a running coroutine reaches await something_not_ready, it hands control back to the event loop, which runs the next ready task. When the socket has data or the timer fires, the loop resumes it where it left off.
time → 0.0s 0.3s
pricing [send]~~~~waiting~~~~[done]
eta [send]~~~~waiting~~~~[done]
offers [send]~~~~waiting~~~~[done]
loop runs ▲ ▲ ▲ idle ▲ ▲ ▲Three consequences:
- Concurrency, not parallelism. One thread, one CPU core. Two tasks never run Python code at the same instant.
- Switches happen only at
await. Code between two awaits runs uninterrupted, which is why a blocking call stalls every other task. - Everything in the chain must be async.
requests,psycopg2andtime.sleepblock. Their async counterparts:httpx.AsyncClient,asyncpgor psycopg 3's async mode, SQLAlchemy'sAsyncSession,asyncio.sleep.
Watch gather overlap the waits
Measured on a laptop, several runs: ~0.90 s vs ~0.30 s. The speed-up equals how many waits you can overlap, not anything about the CPU.
Ten requests, two kinds of sleep
import asyncio, time
async def bad_handler():
time.sleep(0.3) # blocking: the loop can't switch away
return "done"
async def good_handler():
await asyncio.sleep(0.3) # yields: the loop serves others
return "done"
async def serve(handler, n=10):
t = time.perf_counter()
await asyncio.gather(*(handler() for _ in range(n)))
return time.perf_counter() - t
print(f"time.sleep x10: {asyncio.run(serve(bad_handler)):.2f}s")
print(f"asyncio.sleep x10: {asyncio.run(serve(good_handler)):.2f}s")FastAPI: async def is a promise you can break
import asyncio, time
import httpx
from fastapi import FastAPI
app = FastAPI()
@app.get("/async-blocking")
async def async_blocking():
time.sleep(0.2) # a sync driver call hiding in async def
return {"ok": True}
@app.get("/sync-def")
def sync_def():
time.sleep(0.2) # FastAPI runs plain def in a threadpool
return {"ok": True}
@app.get("/async-awaiting")
async def async_awaiting():
await asyncio.sleep(0.2) # a real async driver call
return {"ok": True}
async def hit(path, n=10):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as c:
t = time.perf_counter()
await asyncio.gather(*(c.get(path) for _ in range(n)))
return time.perf_counter() - t
for path in ("/async-blocking", "/sync-def", "/async-awaiting"):
print(f"{path:<16} 10 requests: {asyncio.run(hit(path)):.2f}s")Measured, 10 concurrent requests each:
/async-blocking 10 requests: 2.06s (2.68s on a second run)
/sync-def 10 requests: 0.23s
/async-awaiting 10 requests: 0.20sFastAPI runs a plain def endpoint in a threadpool (40 threads by default via AnyIO), so a blocking call there blocks one thread, not the loop. An async def endpoint runs on the loop, so a blocking call inside it blocks everyone.
The rule I use: async def only if every slow thing inside is awaited. Calling a sync SDK you can't replace? Write def, or wrap the call in await asyncio.to_thread(...). A plain def endpoint is boring, and that's fine.
When async doesn't help at all
CPU-bound work has no waiting to overlap:
import asyncio, time
from concurrent.futures import ProcessPoolExecutor
def count_primes(limit: int) -> int:
return sum(all(n % d for d in range(2, int(n**0.5) + 1)) for n in range(2, limit))
async def as_async(limit):
return count_primes(limit) # no await inside: nothing to overlap
async def on_loop():
return await asyncio.gather(*(as_async(300_000) for _ in range(4)))
async def in_processes():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor(max_workers=4) as pool:
return await asyncio.gather(*(loop.run_in_executor(pool, count_primes, 300_000)
for _ in range(4)))
if __name__ == "__main__":
t = time.perf_counter(); [count_primes(300_000) for _ in range(4)]
print(f"plain loop : {time.perf_counter() - t:.2f}s")
t = time.perf_counter(); asyncio.run(on_loop())
print(f"asyncio.gather : {time.perf_counter() - t:.2f}s")
t = time.perf_counter(); asyncio.run(in_processes())
print(f"4 processes : {time.perf_counter() - t:.2f}s")Measured on a 10-core laptop: plain loop ~1.22 s, asyncio.gather ~1.16 s (no gain: there's no await inside), four processes ~0.44 s. Threads wouldn't help either on standard CPython; the GIL lets one thread run Python bytecode at a time.
At 100× traffic the other lie shows up: async makes it easy to start 10,000 calls at once. Your DB pool has 15 connections (be-006), the partner API rate-limits you at 50 rps, and every unbounded gather becomes a stampede. Cap concurrency with asyncio.Semaphore, put a timeout on every await (asyncio.wait_for), and read be-014 for backpressure.
Where async genuinely wins: many slow, concurrent I/O waits per process, such as fan-out to several services, websockets, long-polling, thousands of idle keep-alive connections.
How async code lies to you
- Blocking calls in
async def:requests,time.sleep,psycopg2,open().read()on a slow disk, bcrypt or scrypt. Each one freezes the loop. - Forgetting
await: you get a coroutine object, not the result, and aRuntimeWarning: coroutine ... was never awaitedin the logs. - Unbounded
gatherover 10,000 ids: you DoS your own database. Use aSemaphore. - No timeouts: one hung partner call holds its task forever.
asyncio.wait_for(coro, 2.0). - One failure cancels the picture: by default
gatherraises the first exception. Usereturn_exceptions=Truewhen partial results are acceptable. - Mixing sync DB sessions into async code: use SQLAlchemy's
AsyncSessionwith an async driver, or stay sync. - "Async = faster": for a CRUD app at 30 rps on Postgres, sync FastAPI with a threadpool is simpler and just as fast.
Words to know
| Coroutine | The paused function object returned by calling an async def; it runs only when awaited or scheduled. |
|---|---|
| Event loop | The single-threaded scheduler that runs whichever task is ready and parks tasks waiting on I/O or timers. |
| await | The point where a coroutine may pause and hand control back to the event loop. |
| Blocking call | A call that waits without yielding to the loop, such as time.sleep or requests.get, stalling every task on that thread. |
| asyncio.gather | Runs awaitables concurrently and returns their results in argument order. |
| asyncio.to_thread | Runs a sync function in a worker thread and lets you await its result without blocking the loop. |
| Semaphore | A counter that limits how many tasks may enter a block at once; used to cap concurrent calls. |
In two minutes
- Async gives one thread many concurrent tasks; the event loop switches only at await.
- It wins when work is mostly waiting on I/O: three 300 ms calls take ~0.9 s awaited in turn, ~0.3 s with gather.
- Calling an async function returns a coroutine; nothing runs until it is awaited or scheduled as a task.
- A blocking call inside async def (time.sleep, requests, a sync driver) freezes every task: 10 × 0.3 s became ~3 s.
- CPU-bound work gains nothing from async; use processes (measured ~1.2 s → ~0.44 s with four).
- In FastAPI, plain def runs in a threadpool, so it's the safe default when anything inside is sync.
- Cap concurrency with a Semaphore and put a timeout on every await, or async makes stampedes easy.
Test yourself
1. What does fare = get_fare("ride_9001") return inside an async function, when get_fare is an async def and you forgot await?
- 250
- A coroutine object; the body hasn't run
- None
- It raises TypeError immediately
Show the answer
A coroutine object; the body hasn't run — Calling an async function only creates a coroutine. Printing its type shows 'coroutine', comparing it to 250 gives False, and Python later warns 'coroutine ... was never awaited'. No error at the call site, which is why it slips through.
2. Why does asyncio.gather over three 300 ms network calls finish in about 0.3 s instead of 0.9 s?
- It runs them on three CPU cores
- It caches the responses
- Each call yields at its await, so the three waits overlap on one thread
- It shortens the timeouts
Show the answer
Each call yields at its await, so the three waits overlap on one thread — All three are scheduled before any is awaited; each sends its request and yields while waiting. It's still one thread on one core: the waits overlap, not the computation.
3. Why can one time.sleep(0.3) inside an async def endpoint slow down every other request on that worker?
- It blocks the thread the event loop runs on, so no other task can be scheduled until it returns
- FastAPI retries the request
- It closes the database pool
- It only slows that one request
Show the answer
It blocks the thread the event loop runs on, so no other task can be scheduled until it returns — The event loop switches tasks only at await. A blocking call never awaits, so the loop's single thread is stuck. Measured: ten such requests took ~3 s instead of ~0.3 s.
4. How should you call a sync third-party SDK (no async version) from an async FastAPI endpoint?
- Call it directly; async def makes it async
- Wrap it in asyncio.gather
- Mark the SDK function async
- Use `await asyncio.to_thread(sdk_call, args)`, or make the endpoint a plain def
Show the answer
Use `await asyncio.to_thread(sdk_call, args)`, or make the endpoint a plain def — to_thread runs the blocking call on a worker thread while the loop keeps serving others. A plain def endpoint gets the same effect from FastAPI's threadpool. Adding async to a function that blocks doesn't stop it blocking.
5. What happens when you run a CPU-heavy prime count four times with asyncio.gather instead of a plain loop?
- About the same time (~1.2 s measured): there is no await to overlap
- About 4× faster
- It deadlocks
- About 4× slower
Show the answer
About the same time (~1.2 s measured): there is no await to overlap — There is no waiting inside, so nothing overlaps; the four tasks run one after another on one thread. Measured ~1.22 s plain vs ~1.16 s gathered. Four processes took ~0.44 s.
6. FastAPI def vs async def: an endpoint calls a sync DB driver that takes 200 ms. Which version handles 10 concurrent requests in about 0.2 s?
- async def
- Both
- def, because FastAPI runs it in a threadpool
- Neither
Show the answer
def, because FastAPI runs it in a threadpool — Measured: async def with a blocking call took ~2.06 s for 10 requests; plain def took ~0.23 s. The async keyword put the blocking call on the loop thread.
There are 9 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.
Start this lesson →