FastAPI Route Handlers: Choosing async def or def
Choose async def for awaitable I/O and def for blocking work in FastAPI. This guide covers constraints, trade-offs, code examples, and validation steps.
25 Aug 2025, 05:07 UTC

The decision: async def or def?
You are designing a FastAPI endpoint and must declare the route handler as either async def or def. The wrong choice can serialize concurrent requests on the event loop or consume thread-pool resources unnecessarily. Use async def when the handler performs awaitable I/O—such as database queries with an async driver or outbound HTTP calls with an async client. Use def for CPU-bound work or blocking libraries that cannot be awaited. FastAPI runs synchronous handlers in a thread pool, so they will not block the event loop, but each request occupies a thread.
Constraints and assumptions
- Python 3.7 or later; FastAPI requires an ASGI server such as Uvicorn or Hypercorn.
- Every call inside an
async defhandler must be awaitable. A blocking call likerequests.get()orpsycopg2.connect()blocks the entire event loop. - Sync handlers are executed by Starlette’s
run_in_threadpool, defaulting to a thread pool sized at CPU count multiplied by five. - Async dependencies (
async defwithDepends) pair with async routes; sync dependencies in async routes are also offloaded to the thread pool.
Supported options compared
| Aspect | async def | def |
|---|---|---|
| Concurrency model | Cooperative multitasking on the ASGI event loop | Thread pool via run_in_threadpool |
| Best fit | Awaitable I/O: async database drivers, httpx, aiofiles | Blocking libraries: requests, psycopg2, CPU-bound tasks |
| Throughput under concurrency | High; many requests can wait on I/O without extra threads | Limited by thread pool size; queued requests wait for a thread |
| Blocking call risk | High; one sync call stalls all async handlers | Low; blocking code runs inside a worker thread |
| Debugging | More complex async stack traces | Standard synchronous traces |
| Dependencies | Supports async and sync dependencies | Sync dependencies only |
Trade-offs by workload
I/O-bound services
If most endpoint latency comes from waiting on a database or external API, async def lets one worker process handle many concurrent requests. The event loop switches to another request while awaiting I/O. This requires async-compatible libraries throughout the call stack.
CPU-bound or blocking services
If the handler performs image processing, cryptography, or calls a synchronous client, def is safer. FastAPI offloads it to a thread, protecting the event loop. The cost is thread memory and context-switching overhead, plus queueing once the thread pool is full.
Mixed handlers in one application
FastAPI supports both styles in the same app. An async route can declare a sync dependency, and FastAPI runs that dependency in the thread pool. Do not assume mixing is free: each sync dependency adds a thread hop and potential latency.
Implementation examples
The examples below assume SQLAlchemy is installed and User is a mapped model. Replace async_session and sync_session with your actual session factories.
Async route handler with async database session
from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
app = FastAPI()
async def get_db() -> AsyncSession:
async with async_session() as session:
yield session
@app.get("/users/{user_id}")
async def read_user(user_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
Sync route handler with blocking database session
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
app = FastAPI()
def get_db() -> Session:
with sync_session() as session:
yield session
@app.get("/users/{user_id}")
def read_user(user_id: int, db: Session = Depends(get_db)):
return db.query(User).filter(User.id == user_id).first()
Validation and checks
Run these commands from the project root in a terminal. They require ordinary user permissions; do not run load tests against a production database.
- Start the API locally:
Expected check: the terminal printsuvicorn main:app --reload --port 8000Uvicorn running on http://127.0.0.1:8000and the app imports without errors. - Inspect the generated OpenAPI schema to confirm both route styles are registered:
Expected check: bothcurl -s http://localhost:8000/openapi.json | jq '.paths | keys'/users/{user_id}paths appear under their respective methods. - Load-test each endpoint separately with a tool such as Locust or Vegeta, targeting 500 concurrent users for one minute. Watch response latency and error rates. Async endpoints should stay flat under concurrency if all downstream calls are awaitable; sync endpoints should degrade once the thread pool is saturated.
- Check for event-loop blocking in async handlers with a profiler such as py-spy:
Expected check: the flame graph shows async I/O waits rather than long-running sync functions on the event loop.py-spy record -o profile.svg -- python -m uvicorn main:app
Limitations
- Thread pool size defaults to CPU count times five. Tune it through Uvicorn workers or Starlette concurrency settings if your sync workload is thread-bound.
- Async database drivers must match your database: asyncpg for PostgreSQL, aiomysql for MySQL, motor for MongoDB. Sync drivers cannot be awaited.
- Python 3.11 and later include asyncio performance improvements; older versions may show higher async overhead.
- Load testing can saturate local resources and affect other services on the same machine. Use a staging environment, not production.
Practical result check
After deploying a handler style, monitor the 95th percentile response time under expected peak concurrency. If async endpoints show rising latency with no increase in throughput, profile for accidental blocking calls. If sync endpoints show thread-queue wait times, either switch to async-compatible libraries or increase thread capacity.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.