FastAPI async def vs def: Performance and When to Use Each
Key takeaways:
async defwins only when the handler awaits async I/O and can yield the loop.defhandlers run in a thread pool, so synchronous blocking is safe there.- The worst case is blocking code inside
async def, which freezes the loop for everyone. - Offload unavoidable blocking calls from
async defwithanyio.to_thread.run_sync. - Confirm your choice by load testing under concurrency, not with a single request.
This guide makes the choice concrete; the underlying model is in Async Correctness and Concurrency.
The Problem This Solves
Developers often assume async def is always faster and convert every route, then call a synchronous database driver inside it and wonder why throughput collapsed. The keyword does not make code asynchronous; awaiting async I/O does. Choosing wrong in either direction costs throughput.
Prerequisites
- FastAPI on Uvicorn.
- A load-testing tool such as
heyorwrkto measure under concurrency.
Step-by-Step Implementation
1. Async I/O → async def
import httpx
@app.get("/profile")
async def profile() -> dict:
# Awaiting async I/O lets the loop serve other requests during the wait.
async with httpx.AsyncClient() as client:
return (await client.get("https://api.example.com/me", timeout=5)).json()
2. Blocking work → def
@app.get("/legacy-report")
def legacy_report() -> dict:
# A sync driver here is fine: FastAPI runs def handlers in a thread pool.
rows = sync_db.query("SELECT ...")
return {"rows": rows}
3. Blocking inside async def → offload
import anyio
@app.get("/digest")
async def digest() -> dict:
# Must call blocking code from async def → offload so the loop stays free.
value = await anyio.to_thread.run_sync(blocking_hash)
return {"digest": value}
Edge Cases and Gotchas
- Hidden blocking. An
async defthat calls a library which blocks internally is as harmful as an explicit blocking call; profile it. - Thread-pool saturation. Many slow
defhandlers exhaust the thread pool and reintroduce queuing; move heavy work to a queue. - CPU-bound work. Threads do not help CPU-bound work because of the GIL; use a process pool.
Verification
Load test both styles under concurrency and compare throughput:
# A correctly async endpoint sustains high concurrency; a blocked one serializes.
hey -z 10s -c 50 http://localhost:8000/profile
If latency scales linearly with concurrency on an async def endpoint, it is blocking the loop somewhere.
Related Reading
- Up to the topic: Async Correctness and Concurrency.
- Related guides: Fixing Blocking Calls in Async Routes and Async Database Sessions.