FastAPI async def vs def: A Decision Guide for Route Handlers and Dependencies
Choose async def or def per FastAPI handler based on blocking behavior. Compare event-loop and threadpool trade-offs, then validate with a concurrency test.
26 Sept 2026, 22:32 UTC

The problem: handler type controls execution context
FastAPI accepts both async def and plain def for path operations and dependencies. The choice is not cosmetic. An async def callable is awaited directly on the event loop; a plain def callable is offloaded to a bounded worker threadpool. Pick the wrong form and you either block every unrelated request or exhaust the pool under load.
Decision and constraints
Decide per path operation and per dependency:
- Use
async defonly when every call inside is non-blocking async I/O (for example,asyncpg,httpx, orawait asyncio.sleep). - Use plain
defwhen the code performs blocking I/O or CPU-bound work—such as a synchronous database driver,requests,time.sleep, or a tight computation loop—so FastAPI runs it in a worker thread. - If a handler is mostly async but contains one unavoidable blocking step, keep the handler
async defand wrap only that step withrun_in_threadpool.
Three constraints shape the choice: expected latency under concurrency, whether your downstream client is sync or async, and the capacity of the threadpool (which is bounded and version-dependent).
Supported options compared
| Option | Runs on | Best for | Main risk |
|---|---|---|---|
async def | Event loop | Async I/O (asyncpg, httpx) | A blocking call stalls all requests |
def | AnyIO threadpool | Blocking I/O (sync DB, requests) | Bounded pool exhaustion under load |
async def + run_in_threadpool | Loop + threadpool | Mostly async with a blocking step | Added complexity; still bounded pool |
Trade-offs in practice
Event-loop starvation
FastAPI and Starlette route synchronous callables through anyio.to_thread.run_sync on a bounded threadpool, while async def callables are awaited directly. If you place a blocking call—a sync database driver, requests.get, time.sleep, or a CPU-bound loop—inside an async def handler, the event loop cannot process other tasks. Unrelated endpoints stall until the call returns.
Threadpool exhaustion
Plain def handlers and dependencies run in the threadpool. At low concurrency the extra hop is cheap. Under load, many requests blocking on slow downstreams can consume every worker thread, causing new sync endpoints to queue. The event loop itself stays responsive, but sync work starves.
Dependencies and shared state
Async dependencies are awaited once per request and can be shared across endpoints. Sync dependencies add a threadpool hop per use. That is fine for occasional blocking setup, but a sync dependency called by many concurrent requests multiplies thread demand.
CPU-bound work
Neither the event loop nor the default threadpool removes the GIL for CPU-intensive Python code. Heavy computation belongs in a process pool or an external worker, not in a request handler.
Concrete implementation and validation
The following minimal application exposes three endpoints that differ only in handler style and sleep mechanism, plus a lightweight health check.
# main.py
import asyncio
import time
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/async-sleep")
async def async_sleep():
await asyncio.sleep(0.5)
return {"mode": "async", "slept": 0.5}
@app.get("/sync-sleep")
def sync_sleep():
time.sleep(0.5)
return {"mode": "sync", "slept": 0.5}
@app.get("/blocking-async")
async def blocking_async():
time.sleep(0.5) # blocks the event loop
return {"mode": "blocking-async", "slept": 0.5}
Run the app from your project root with:
uvicorn main:app --host 127.0.0.1 --port 8000
No elevated permissions are required beyond binding to a local port. Replace main:app if your module path differs.
Concurrency validation script
From the same project root, run a script that fires concurrent requests and measures wall-clock time. The example below uses httpx and asyncio; install it with pip install httpx if needed.
# validate_concurrency.py
import asyncio
import time
import httpx
BASE_URL = "http://127.0.0.1:8000"
CONCURRENCY = 20
async def fetch(client, path):
start = time.perf_counter()
r = await client.get(f"{BASE_URL}{path}", timeout=30.0)
r.raise_for_status()
return time.perf_counter() - start
async def run_path(path):
async with httpx.AsyncClient() as client:
start = time.perf_counter()
await asyncio.gather(*[fetch(client, path) for _ in range(CONCURRENCY)])
total = time.perf_counter() - start
print(f"{path}: {CONCURRENCY} requests in {total:.2f}s")
async def main():
await run_path("/async-sleep")
await run_path("/sync-sleep")
# Expect /blocking-async to serialize and /health to stall during the run.
await run_path("/blocking-async")
if __name__ == "__main__":
asyncio.run(main())
Execute it with python validate_concurrency.py. Expected behavior, not measured output:
/async-sleep: Becauseawait asyncio.sleepyields to the loop, the 20 requests overlap and finish in roughly 0.5–1.0 s total./sync-sleep: The 20 requests overlap up to the threadpool limit. Total time is approximately0.5 × ceil(20 / threadpool_limit)seconds./blocking-async: Eachtime.sleepblocks the loop, so requests serialize to about20 × 0.5 = 10 s. While this runs, a request to/healthwill hang until the loop is free.
Checking the event loop during the blocking test
In a separate terminal, poll the health endpoint while /blocking-async is under load:
while true; do curl -s -m 2 http://127.0.0.1:8000/health || echo "timeout"; sleep 0.2; done
If the loop is stalled, curl will hit the 2-second timeout. Repeat with /sync-sleep under load; /health should remain responsive because the blocking work happens in the threadpool.
Limitations and version assumptions
- The default threadpool capacity and offloading behavior vary across FastAPI, Starlette, and AnyIO versions. Confirm the effective limit in your runtime rather than assuming a fixed number.
run_in_threadpoolkeeps the event loop responsive but does not remove the GIL. CPU-bound work still needs a process pool or external worker.- Load-test results depend on the host machine, operating-system thread limits, and the behavior of downstream drivers.
- This guide describes well-established FastAPI behavior. After upgrading FastAPI, Starlette, or AnyIO, re-run the concurrency check.
Practical verification checklist
- Pin and print the versions of FastAPI, Starlette, and AnyIO in your environment.
- Run the concurrency script above and compare wall-clock totals across the three endpoints.
- Poll
/healthduring the/blocking-asynctest to confirm loop starvation, then repeat with/sync-sleepto confirm the loop stays free. - Audit every dependency: if it performs blocking I/O, declare it as
defor wrap the blocking step withrun_in_threadpool.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.