Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 081 / 100

FastAPI common mistakes: real-world lessons and examples

Pitfalls that slow down FastAPI apps.

Expona Admin7 min read

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:

python
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:

python
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:

python
@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:

python
@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:

python
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:

python
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:

python
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:

python
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:

python
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:

python
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:

python
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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles