FastAPI is an async framework built on ASGI, but it has a clever trick: it handles synchronous endpoints just as well as async ones. Understanding when to use async def versus def is one of the most important decisions in a FastAPI application, and it is the decision AI gets wrong most often.
How FastAPI runs your endpoints
When FastAPI receives a request, it looks at your endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. function's signature to decide how to run it.
async def endpoints
@app.get("/async-endpoint")
async def async_handler():
result = await some_async_operation()
return {"data": result}An async def function runs directly on the main event loopWhat is event loop?The mechanism that lets Node.js handle many operations on a single thread by delegating slow tasks and processing their results when ready. (the asyncio loop managed by Uvicorn). The event loop is single-threadedWhat is single-threaded?A model where one main execution thread handles all work - Node.js uses this with an event loop to handle many requests concurrently.. While your function is running, no other coroutine can execute, unless your function hits an await, which yields control back to the loop.
This is powerful: await lets the event loop handle thousands of concurrent requests on a single thread, because each request yields control while waiting for I/O. But it comes with a strict rule: you must never block inside an async def function.
def endpoints
@app.get("/sync-endpoint")
def sync_handler():
result = some_blocking_operation()
return {"data": result}A plain def function is automatically offloaded to an external thread pool. FastAPI detects that the function is not a coroutine and runs it in a separate thread, keeping the main event loop free to handle other requests.
This means that blocking calls inside a def endpoint are safe. The thread blocks, but the event loop does not notice, it is busy handling other requests on other threads.
The mental model
Think of the event loopWhat is event loop?The mechanism that lets Node.js handle many operations on a single thread by delegating slow tasks and processing their results when ready. as a single-lane highway. await is like pulling into a restWhat is rest?An architectural style for web APIs where URLs represent resources (nouns) and HTTP methods (GET, POST, PUT, DELETE) represent actions on those resources. stop: you free the lane for other cars. Blocking calls (time.sleep, requests.get) are like parking your car in the middle of the lane, every other car behind you stops.
def endpoints run on side roads (the thread pool). Even if you park in the middle of a side road, the highway is unaffected.
| Endpoint type | Runs on | Blocking I/O inside | Effect |
|---|---|---|---|
async def | Main event loop | Blocks the entire loop | All requests stall |
def | Thread pool | Blocks one thread | Other requests unaffected |
The cardinal sin: blocking inside async def
This is the most common FastAPI bug AI generates, and it is invisible during development (when you have one request at a time) but catastrophic in production.
import time
import requests
# WRONG - blocking calls inside async def
@app.get("/bad-endpoint")
async def bad_handler():
time.sleep(5) # Blocks the event loop for 5 seconds
response = requests.get("https://api.example.com/data") # Also blocks
return {"data": response.json()}While time.sleep(5) is running, the event loopWhat is event loop?The mechanism that lets Node.js handle many operations on a single thread by delegating slow tasks and processing their results when ready. is frozen. No other request can be processed. If ten users hit this endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. simultaneously, the last user waits 50 seconds. Under real load, the server becomes unresponsive.
The requests library is synchronous. Every call to requests.get() blocks the event loop until the external APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. responds. If that API takes 2 seconds, your entire FastAPI application is frozen for 2 seconds per request.
async def because it looks modern and "correct." Then it uses time.sleep() for delays, requests.get() for HTTP calls, and synchronous database drivers, all blocking calls inside async functions. This is the number one FastAPI production bug caused by AI-generated code.The correct approach
Option 1: use def for blocking I/O
The simplest fix is to drop the async. FastAPI runs the function in a thread pool, and blocking calls are safe.
import time
import requests
# CORRECT - blocking calls inside def, runs in thread pool
@app.get("/good-sync-endpoint")
def good_sync_handler():
time.sleep(5) # Blocks this thread, not the event loop
response = requests.get("https://api.example.com/data")
return {"data": response.json()}This is the right choice when you are using synchronous libraries that do not have async alternatives, or when migrating legacy code to FastAPI.
Option 2: use async def with async libraries
If you want the full performance benefits of async, use libraries designed for await.
import asyncio
import httpx
# CORRECT - async calls inside async def
@app.get("/good-async-endpoint")
async def good_async_handler():
await asyncio.sleep(5) # Yields control, does not block
async with httpx.AsyncClient() as client:
response = await client.get("https://api.example.com/data")
return {"data": response.json()}| Blocking call | Async replacement |
|---|---|
time.sleep(n) | await asyncio.sleep(n) |
requests.get(url) | await httpx.AsyncClient().get(url) |
requests.post(url, json=data) | await httpx.AsyncClient().post(url, json=data) |
sqlite3.connect() | await aiosqlite.connect() |
psycopg2.connect() | await asyncpg.connect() |
open(file).read() | await aiofiles.open(file).read() |
Thread pool starvation: the silent killer
Even def endpoints are not immune to problems. FastAPI's default thread pool has a limited number of threads (typically 40). If every thread is occupied by a slow blocking call, new requests queue up and the server becomes unresponsive.
# Each call blocks a thread for 30 seconds
@app.get("/slow-report")
def generate_report():
result = run_expensive_database_query() # Takes 30 seconds
return {"report": result}If 40 users hit this endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. simultaneously, all 40 threads are busy. The 41st user waits until a thread frees up. This is thread pool starvation.
The solutions:
- Use async database drivers so you do not consume threads
- Offload heavy work to a background task queue (Celery, Redis Queue)
- Increase the thread pool size as a temporary fix (but this consumes more memory)
- Add timeouts to prevent indefinitely blocking threads
The decision flowchart
When reviewing AI-generated FastAPI code, ask this sequence of questions for each endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users.:
- Does the endpoint call any blocking function (
time.sleep,requests.get, synchronous DB driver)? - If yes, is the endpoint
async def? - If yes, this is a bug. Either change it to
defor replace the blocking calls with async alternatives.
Is there blocking I/O?
├── No → async def is fine (e.g., returning static data)
└── Yes → Is there an async alternative?
├── Yes → Use async def + await
└── No → Use def (thread pool handles it)asyncio.to_thread():> result = await asyncio.to_thread(requests.get, "https://api.example.com")
>This works, it runs the blocking call in a thread, but it is unnecessary complexity. If you are going to use a thread anyway, just make the endpoint
def and let FastAPI handle the threading automatically. asyncio.to_thread() makes sense only when most of the endpoint is async and one specific call is blocking.Mixing async and sync in the same app
A FastAPI application can have both async def and def endpoints. This is normal and expected. Each endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. is evaluated independently.
# Async - calls async database driver
@app.get("/users")
async def get_users():
users = await async_db.fetch_all("SELECT * FROM users")
return users
# Sync - uses a legacy library that only supports blocking calls
@app.post("/reports")
def generate_report(params: ReportParams):
report = legacy_report_engine.generate(params) # blocking
return {"report_url": report.url}This is perfectly valid. The async endpoint runs on the event loopWhat is event loop?The mechanism that lets Node.js handle many operations on a single thread by delegating slow tasks and processing their results when ready.; the sync endpoint runs in the thread pool. They do not interfere with each other.
Real-world example: spot the bug
Here is a typical AI-generated FastAPI application. Find the problem:
from fastapi import FastAPI
import requests
import asyncio
app = FastAPI()
@app.get("/weather/{city}")
async def get_weather(city: str):
response = requests.get(
f"https://api.weather.com/v1/{city}",
params={"key": "abc123"}
)
return response.json()
@app.get("/forecast/{city}")
async def get_forecast(city: str):
await asyncio.sleep(1) # Rate limit pause
response = requests.get(
f"https://api.weather.com/v1/{city}/forecast",
params={"key": "abc123"}
)
return response.json()Both endpoints are async def but use requests.get(), a synchronous HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. client. The get_weather endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. blocks the event loopWhat is event loop?The mechanism that lets Node.js handle many operations on a single thread by delegating slow tasks and processing their results when ready. during the APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. call. The get_forecast endpoint is even sneakier: it correctly uses await asyncio.sleep(1) for the pause, but then blocks the event loop with requests.get(). The await on one line does not make the blocking call on the next line async.
The fix is either changing both to def or replacing requests with httpx.AsyncClient:
import httpx
@app.get("/weather/{city}")
async def get_weather(city: str):
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.weather.com/v1/{city}",
params={"key": "abc123"}
)
return response.json()