FastAPI common mistakes: real-world lessons and examples
We've all shipped FastAPI applications that felt performant in development, only to see them struggle under load in production. FastAPI common mistakes: real-world lessons and examples covers the pitfalls that silently degrade performance and reliability—issues we've debugged across e-commerce checkouts, CRM backends, and real-time ride-hailing systems. Understanding these traps early saves months of firefighting later.
The blocking calls inside async def trap
The most common mistake we see is mixing synchronous blocking operations into async functions. FastAPI's event loop runs on a single thread per worker. When you call a blocking function—a database query without an async driver, a sleep(), a CPU-intensive calculation—the entire event loop stops. Every other request waiting on that worker is frozen.
Consider this real scenario from a travel booking backend:
from fastapi import FastAPI
from time import sleep
import httpx
app = FastAPI()
@app.get("/book")
async def book_flight(flight_id: int):
# Blocking call inside async def - WRONG
sleep(2) # Simulates a sync operation
price = get_price_from_legacy_api(flight_id)
return {"status": "booked", "price": price}
def get_price_from_legacy_api(flight_id: int):
# Synchronous HTTP call
response = httpx.Client().get(f"https://legacy-api.com/price/{flight_id}")
return response.json()
With this code, a single slow request blocks everyone. If you have 10 concurrent requests and each one hits this function, all 10 workers are locked. The solution is to move blocking work to a thread pool or switch to async equivalents:
from fastapi import FastAPI
from asyncio import sleep
import httpx
from concurrent.futures import ThreadPoolExecutor
app = FastAPI()
executor = ThreadPoolExecutor(max_workers=5)
@app.get("/book")
async def book_flight(flight_id: int):
# Non-blocking sleep
await sleep(2)
# Run blocking code in thread pool
loop = asyncio.get_event_loop()
price = await loop.run_in_executor(
executor,
get_price_from_legacy_api,
flight_id
)
return {"status": "booked", "price": price}
Even better, use an async HTTP client:
@app.get("/book")
async def book_flight(flight_id: int):
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://legacy-api.com/price/{flight_id}"
)
price = response.json()
return {"status": "booked", "price": price}
The rule: if your database, cache, or HTTP client doesn't have an async version readily available, thread pools are your friend. But always prefer native async drivers when possible.
Creating a new HTTP client per request
We've reviewed production code that opened a fresh HTTP client for every single request. This is wasteful and defeats connection pooling. Each httpx.Client() or aiohttp.ClientSession() initialization carries overhead: DNS resolution, SSL handshakes, connection establishment. Creating one per request means repeating all of that for every call to an external API.
From a SaaS platform we worked on, the mistake looked like this:
@app.post("/webhook/process")
async def process_webhook(data: dict):
# Creating new client for every request - WRONG
async with httpx.AsyncClient() as client:
response = await client.post(
"https://external-service.com/notify",
json={"event": data}
)
return {"received": True}
If you receive 100 webhooks per second, you're opening 100 new HTTP sessions, each with its own connection pool. The solution is to instantiate the client once at application startup and reuse it:
import httpx
from contextlib import asynccontextmanager
client: httpx.AsyncClient = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global client
client = httpx.AsyncClient(
timeout=10.0,
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20)
)
yield
await client.aclose()
app = FastAPI(lifespan=lifespan)
@app.post("/webhook/process")
async def process_webhook(data: dict):
# Reuse the global client
response = await client.post(
"https://external-service.com/notify",
json={"event": data}
)
return {"received": True}
The same principle applies to database connections. Use connection pools (most async ORM drivers handle this automatically), and let them persist across requests. This single change can reduce latency by 50-200ms per call in high-throughput systems.
Returning ORM objects without response models
It's tempting to return SQLAlchemy or Tortoise ORM objects directly from your endpoint. FastAPI will serialize them, but you're losing control over what gets sent to the client—and inviting performance and security issues.
From a CRM backend, we saw this pattern repeatedly:
from sqlalchemy.orm import Session
from fastapi import FastAPI, Depends
@app.get("/users/{user_id}")
async def get_user(user_id: int, db: Session = Depends(get_db)):
# Returning ORM object directly - WRONG
user = db.query(User).filter(User.id == user_id).first()
return user
Problems cascade:
- Lazy loading: FastAPI serializes the object after the database session closes. Accessing related fields (user.orders) triggers new queries outside the session context—causing DetachedInstanceError or the N+1 query problem.
- Accidental data leaks: Hashed passwords, internal IDs, or sensitive flags get serialized if they're on the model.
- No versioning: Changing the ORM schema instantly changes your API contract.
The solution is Pydantic response models:
from pydantic import BaseModel
from sqlalchemy.orm import Session
from fastapi import FastAPI, Depends
class UserResponse(BaseModel):
id: int
email: str
name: str
class Config:
from_attributes = True
@app.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
return user
If you need related data (orders, payments), fetch it explicitly and use nested Pydantic models:
class OrderResponse(BaseModel):
id: int
total: float
class UserWithOrdersResponse(BaseModel):
id: int
email: str
orders: list[OrderResponse]
class Config:
from_attributes = True
@app.get("/users/{user_id}/orders", response_model=UserWithOrdersResponse)
async def get_user_with_orders(user_id: int, db: Session = Depends(get_db)):
user = db.query(User).options(
joinedload(User.orders)
).filter(User.id == user_id).first()
return user
Response models also enable FastAPI to generate accurate OpenAPI docs and validate outgoing data—a safety net we don't want to skip.
Running one worker in production
FastAPI applications deployed with a single Uvicorn worker are single-threaded in the event loop sense. You can't scale vertically or handle traffic spikes. We've seen e-commerce platforms go down during flash sales because they shipped with one worker and a single slow request paralyzed the entire service.
The mistake is often unconscious—running FastAPI without a process manager:
uvicorn main:app --host 0.0.0.0 --port 8000
This starts one worker. If that process crashes, your service is offline. If traffic spikes, requests queue and timeout. The fix is running multiple workers behind a load balancer:
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
How many workers? A common formula: (2 × CPU cores) + 1. For a 4-core machine, start with 9 workers. Monitor CPU and memory under load, then adjust. Use a process manager like Gunicorn to wrap Uvicorn:
gunicorn main:app \
--workers 9 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000
In containerized environments (Docker/Kubernetes), run one worker per container and let orchestration handle scaling. This way, a container restart only affects one worker, not your entire fleet.
Common mistakes at a glance
| Mistake | Impact | Fix |
|---|---|---|
| Blocking calls in async functions | Event loop freezes, all requests stall | Use async drivers or thread pool executor |
| New HTTP client per request | Lost connection pooling, high latency | Create client once, reuse globally |
| Returning raw ORM objects | N+1 queries, data leaks, tight coupling | Use Pydantic response models |
| Single worker in production | No redundancy, poor concurrency | Run multiple workers, use process manager |
Key takeaways
- Respect the event loop—async def must call await, never block. Thread pools and async drivers are your escape hatches.
- Pool resources at startup—HTTP clients, database connections, and other expensive objects should live for the lifetime of your application, not per request.
- Decouple API responses from ORM models—Pydantic response models protect you from lazy loading, data leaks, and schema drift.
- Plan for concurrency from day one—run multiple workers and load-balance across them. A single worker is a single point of failure.






Comments
Loading comments…