Skip to content

FastAPI Async in 2026: The def vs async def Choice I Keep Getting Right

FastAPI Async in 2026: The def vs async def Choice I Keep Getting Right

Short version for the impatient: in FastAPI, async def doesn’t make your endpoint faster. It just means “please run me on the event loop, and I promise not to block it”. If you break that promise, one slow request can freeze every other request the worker is handling. If you use plain def, FastAPI runs the endpoint in a threadpool and blocking is fine. That’s the whole trick, and I’ve watched senior engineers get it backwards for years.

I’m writing this because I did an on-call rotation last month where the paging alert was “P99 latency 30 seconds” and the root cause was a single requests.get() call sitting inside an async def handler. The fix was a one-word edit. The pain was two hours of me squinting at flame graphs first.

Why async def is a promise, not a superpower

FastAPI’s routing is a two-tier system, and the official concurrency docs spell this out if you read carefully. When you declare a route:

@app.get("/thing")
async def get_thing():
    ...

FastAPI schedules this on the event loop. That loop is single-threaded per worker. If your handler does anything blocking (a sync HTTP call, a sync DB query, a time.sleep, a heavy CPU loop), that entire worker stops serving other requests until it returns.

Change the signature:

@app.get("/thing")
def get_thing():
    ...

Now FastAPI runs the function in an anyio threadpool (default 40 threads, tunable via anyio.to_thread.current_default_thread_limiter().total_tokens). Blocking calls are fine because the event loop stays free. You lose zero performance for CPU-light endpoints, and you get correctness for free. The anyio threading docs explain the exact semantics if you want the underlying model.

The rule I keep in my head: use async def only if every I/O call inside it is genuinely async. Otherwise use plain def. Not “async def because it’s the modern one”. Not “async def because I use FastAPI”. def unless you actually mean it.

The failure mode nobody warns you about

Here’s the incident from last month, sanitized. This is the code that shipped:

import requests
from fastapi import FastAPI

app = FastAPI()

@app.get("/user/{uid}/profile")
async def get_profile(uid: str):
    # calling a slow internal service
    r = requests.get(f"http://users-svc/internal/{uid}", timeout=30)
    return r.json()

Under normal load, requests.get returned in 40ms and nothing looked wrong. Then the internal users service had a 20-second cold-start after a deploy. Every single in-flight request on that worker queued behind the blocked handler, because requests is sync and it froze the event loop. P99 shot to 30 seconds even for /healthz.

The fix was one of two options:

# Option A: keep the endpoint sync, let the threadpool handle it
@app.get("/user/{uid}/profile")
def get_profile(uid: str):
    r = requests.get(f"http://users-svc/internal/{uid}", timeout=30)
    return r.json()
# Option B: go fully async with httpx
import httpx

@app.get("/user/{uid}/profile")
async def get_profile(uid: str):
    async with httpx.AsyncClient() as client:
        r = await client.get(f"http://users-svc/internal/{uid}", timeout=30)
    return r.json()

Option A is a one-word change. Option B is what you want if this endpoint is hot and you’re doing fan-out to several services from the same handler. Both are correct. The half-and-half version I shipped was neither.

Where async actually pays off

I don’t want to give the impression that async def is a footgun with no upside. It isn’t. It’s a huge win in three specific shapes:

Fan-out I/O. One request needs to hit five services or five DB queries that don’t depend on each other. With async def and asyncio.gather, you fire them in parallel and wait for the slowest. The threadpool version has to serialize or thread-fan them yourself.

import asyncio, httpx

@app.get("/dashboard/{uid}")
async def dashboard(uid: str):
    async with httpx.AsyncClient() as client:
        user, orders, notifs = await asyncio.gather(
            client.get(f"/users/{uid}"),
            client.get(f"/orders?uid={uid}"),
            client.get(f"/notifications?uid={uid}"),
        )
    return {"user": user.json(), "orders": orders.json(), "notifs": notifs.json()}

Long-lived connections. Websockets, SSE streams, LLM token streaming. The event loop is the correct primitive here. Trying to hold 5,000 threads open for 5,000 open streams will run you out of memory before it runs you out of CPU.

Background waiting. Anything that spends 99% of its time waiting on the network. An async worker with 4 CPUs will comfortably handle 10,000 concurrent I/O-bound connections. A threaded worker maxes out around a few hundred before context switching costs eat you.

A caveat worth calling out: if you’re running FastAPI behind Uvicorn workers and you set --workers 8 on an 8-core box, each worker has its own event loop and its own threadpool. Blocking one worker doesn’t block the others, so a mixed handler is less catastrophic in production than it looks in a local one-worker test. It’s still wrong, it just fails less loudly.

If your endpoint doesn’t fit one of those shapes, def is almost always the correct choice, and you should stop feeling guilty about it.

The database question (SQLAlchemy 2.0 makes this easier)

The most common trap I see now: async def handler + sync SQLAlchemy session. Congratulations, you just moved every DB query onto the event loop with no way for it to yield.

If you want async endpoints and a real DB, use the async session:

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker

engine = create_async_engine("postgresql+asyncpg://...")
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)

async def get_session():
    async with AsyncSessionLocal() as session:
        yield session

@app.get("/orders/{oid}")
async def get_order(oid: int, db: AsyncSession = Depends(get_session)):
    result = await db.execute(select(Order).where(Order.id == oid))
    return result.scalar_one()

SQLAlchemy 2.0’s async API is genuinely nice. It’s the same select/insert/update shape as sync, just with await on execute and commit. The SQLAlchemy 2.0 async docs are the canonical reference and they don’t hide the sharp edges (no lazy loading, be careful with expire_on_commit).

If you’re not ready to migrate to async DB, keep your endpoint as def and use the sync session. That’s a perfectly fine production choice. Don’t mix.

How I write new endpoints now

My mental checklist before writing a new route:

  1. List every I/O call this endpoint will make. DB, HTTP, cache, filesystem, subprocess.
  2. Is every one of them async-native? (async DB session, httpx.AsyncClient, aioredis, etc.)
  3. If yesasync def.
  4. If no, or you’re not suredef.
  5. If it’s mostly CPU workdef, and consider offloading to a Celery worker if it’s heavy.

That’s it. Five bullets, taped mentally to the inside of my monitor. You don’t need to think harder than this. The rule captures every case I’ve hit in five years of shipping FastAPI, including the ones I got wrong.

If you want to sanity-check whether your existing service has the problem, throw asyncio.run(asyncio.sleep(0)) in your endpoint and load-test it. If a slow endpoint blocks a fast one, you have a mixed handler somewhere. I built a small profiler for this a while back and wrote about it in my post on Postgres full-text search, where I hit the same event-loop trap on a search endpoint.

What I’d do this week if I inherited a FastAPI codebase

Run this ripgrep in the repo:

rg -A 3 'async def' | rg -B 1 -E '(requests\.|psycopg2|pymysql|time\.sleep|subprocess\.run)'

Every match is a place someone put a blocking call inside an async handler. Convert the endpoint to plain def or swap the library for an async one. That’s usually an afternoon of work and it will save you a P99 incident.

If the codebase is large and you want a longer-term fix, I keep notes on Python backend architecture over on my work page. Happy to trade war stories about event loops and threadpools.