David Gaitán
← Back to blog
Python

FastAPI in production: the mistakes I keep seeing (and fixing)

· 9 min read

I've worked on several different FastAPI projects lately — some greenfield, some inherited from other teams — and I keep running into the same handful of issues or bad practices across all of them. Different codebases, different domains, same mistakes. So I figured it's worth writing down the ones I see most often, both as a reference for myself and in case they save someone else a rough production incident.

FastAPI is easy to pick up and deceptively easy to get subtly wrong. Most of the mistakes I see aren't syntax errors — the app runs, the docs page looks great, the demo works. They're structural: async endpoints that block the event loop, response models built by hand instead of by Pydantic, a fresh DB connection wired into every route. None of it crashes in dev. All of it falls over under load.

Here's the list I actually check for when I review a FastAPI codebase.

1. async def doesn't mean "non-blocking" — you still have to earn it

This is the one that bites people hardest, because it's invisible until traffic shows up. Declaring a route async def doesn't make the code inside it non-blocking. If you call a blocking library inside an async def route, you've frozen the entire event loop for every other request being served by that worker.

Bad — looks async, blocks everything:

import time
import requests
from fastapi import FastAPI

app = FastAPI()

@app.get("/weather")
async def get_weather():
    # requests is synchronous. This blocks the whole event loop.
    response = requests.get("https://api.weather.example/current")
    time.sleep(1)  # simulating any blocking call — DB driver, file IO, whatever
    return response.json()

While that requests.get call is blocking, every other request hitting that worker — not just this one — is stuck waiting. Under load this turns into cascading latency that has nothing to do with your actual bottleneck.

Good — either go fully async, or admit you're blocking and offload it:

import httpx
from fastapi import FastAPI

app = FastAPI()

@app.get("/weather")
async def get_weather():
    async with httpx.AsyncClient() as client:
        response = await client.get("https://api.weather.example/current")
        return response.json()

If the library you need doesn't have an async version — some ORMs, some SDKs, some CPU-heavy work — don't fake it with async def. Use a plain def route instead. FastAPI automatically runs synchronous route functions in a thread pool, which is exactly what you want for blocking code:

import requests
from fastapi import FastAPI

app = FastAPI()

@app.get("/weather-legacy")
def get_weather_legacy():
    # Plain def — FastAPI runs this in a threadpool, not on the event loop.
    response = requests.get("https://api.weather.example/current")
    return response.json()

The rule of thumb: async def is a promise that nothing inside will block. If you can't keep that promise, use def and let FastAPI's threadpool handle it for you.

2. Writing actually async-friendly code

Once you've decided a route is genuinely async, keep it that way end to end. A single time.sleep(), a synchronous psycopg2 call, or a blocking open() buried three functions deep undoes everything.

Bad:

@app.get("/report")
async def generate_report():
    data = fetch_from_db_sync()       # blocking DB driver
    with open("template.txt") as f:   # blocking file IO
        template = f.read()
    return {"report": template.format(data=data)}

Good:

import aiofiles

@app.get("/report")
async def generate_report():
    data = await fetch_from_db_async()
    async with aiofiles.open("template.txt") as f:
        template = await f.read()
    return {"report": template.format(data=data)}

If you're doing genuinely CPU-bound work (image processing, heavy serialization, crunching numbers), async doesn't help you anyway — that's not an I/O wait, it's the CPU being busy. Push it into a background worker (Celery, arq, whatever you're already running) instead of pretending it's an async problem.

3. Understand the Dependency Injection rule, and lean on it

FastAPI's Depends() system is the single most underused feature in codebases I review. People reach for it to inject a DB session and then stop, missing that it's a general composition tool for anything a route needs: auth, pagination params, feature flags, shared validation.

Bad — logic duplicated in every route:

@app.get("/orders")
async def list_orders(token: str):
    if not token or not verify_token(token):
        raise HTTPException(401, "Invalid token")
    user = get_user_from_token(token)
    return db.get_orders(user.id)

@app.get("/invoices")
async def list_invoices(token: str):
    if not token or not verify_token(token):
        raise HTTPException(401, "Invalid token")
    user = get_user_from_token(token)
    return db.get_invoices(user.id)

Good — the check lives in one place, and every route just declares it needs a User:

from fastapi import Depends, HTTPException, Header

async def get_current_user(authorization: str = Header(...)) -> User:
    if not authorization or not verify_token(authorization):
        raise HTTPException(401, "Invalid token")
    return get_user_from_token(authorization)

@app.get("/orders")
async def list_orders(user: User = Depends(get_current_user)):
    return db.get_orders(user.id)

@app.get("/invoices")
async def list_invoices(user: User = Depends(get_current_user)):
    return db.get_invoices(user.id)

The rule I use: if two routes repeat the same "check X before doing the real work" logic, that's a dependency, not a copy-paste. Dependencies are also composable — a dependency can depend on another dependency — so auth, rate limiting, and tenant scoping can each live in their own function and stack cleanly on the routes that need them.

4. Don't expose /docs and /redoc in production

The auto-generated Swagger UI is fantastic for local development and for handing an API to a frontend team. It's also a gift-wrapped map of your entire API surface — every route, every schema, every field name — sitting at a predictable URL for anyone who tries it.

Bad — docs live at the default path in every environment:

from fastapi import FastAPI

app = FastAPI(title="Internal Billing API")

Good — gate them behind environment config:

import os
from fastapi import FastAPI

ENV = os.getenv("APP_ENV", "development")
IS_PROD = ENV == "production"

app = FastAPI(
    title="Internal Billing API",
    docs_url=None if IS_PROD else "/docs",
    redoc_url=None if IS_PROD else "/redoc",
    openapi_url=None if IS_PROD else "/openapi.json",
)

If you still need docs in production for internal tooling, put them behind auth (a dependency on the docs route, or an API gateway rule) rather than leaving them world-readable. Don't rely on "security through obscurity" either — the exact /docs path is the first thing any scanner tries.

5. Create a custom Pydantic base model

Every serious FastAPI project ends up wanting the same handful of Pydantic config tweaks — consistent datetime serialization, orm_mode/from_attributes for reading from SQLAlchemy models, maybe camelCase aliasing for a JS frontend. Doing that per-schema is how you end up with twenty slightly-different models.

Bad — config repeated (and drifting) across every schema:

from pydantic import BaseModel
from datetime import datetime

class UserOut(BaseModel):
    id: int
    email: str
    created_at: datetime

    class Config:
        from_attributes = True

class OrderOut(BaseModel):
    id: int
    total: float
    created_at: datetime

    class Config:
        orm_mode = True  # someone forgot this project moved to from_attributes

Good — one base model, one source of truth:

from pydantic import BaseModel, ConfigDict

class AppBaseModel(BaseModel):
    model_config = ConfigDict(
        from_attributes=True,
        populate_by_name=True,
        str_strip_whitespace=True,
    )

class UserOut(AppBaseModel):
    id: int
    email: str
    created_at: datetime

class OrderOut(AppBaseModel):
    id: int
    total: float
    created_at: datetime

Every model that inherits from AppBaseModel gets the same behavior automatically. When you need to change it project-wide — say, adding a shared to_camel alias generator — you change it once.

6. Don't hand-build response dictionaries — use response_model

I still see routes that build the response by hand, field by field, instead of letting a Pydantic model do it. This throws away validation, throws away the OpenAPI schema generation, and silently leaks fields you didn't mean to expose.

Bad — manual dict construction, easy to leak fields:

@app.get("/users/{user_id}")
async def get_user(user_id: int):
    user = db.get_user(user_id)
    return {
        "id": user.id,
        "email": user.email,
        "password_hash": user.password_hash,  # oops
        "created_at": str(user.created_at),
    }

That password_hash line is exactly the kind of thing that survives code review because the response "looks fine" in a quick test — nobody notices the extra field until it shows up in a security audit.

Good — declare the shape, let FastAPI enforce it:

@app.get("/users/{user_id}", response_model=UserOut)
async def get_user(user_id: int):
    user = db.get_user(user_id)
    return user  # UserOut filters this to id, email, created_at — nothing else

With response_model=UserOut, FastAPI serializes the ORM object through the schema, drops anything not declared on UserOut, and documents the exact response shape in /docs automatically. It's less code and it's structurally impossible to leak password_hash by accident, because the model simply doesn't have that field.

7. Validate with Pydantic, not with hand-rolled if statements

If you're writing if not email or "@" not in email: raise HTTPException(...) inside a route body, you're reimplementing what a type annotation should be doing for you.

Bad:

@app.post("/signup")
async def signup(payload: dict):
    email = payload.get("email")
    age = payload.get("age")
    if not email or "@" not in email:
        raise HTTPException(400, "Invalid email")
    if not isinstance(age, int) or age < 18:
        raise HTTPException(400, "Must be an adult")
    return create_user(email, age)

Good:

from pydantic import BaseModel, EmailStr, Field

class SignupRequest(AppBaseModel):
    email: EmailStr
    age: int = Field(ge=18, description="Must be an adult")

@app.post("/signup")
async def signup(payload: SignupRequest):
    return create_user(payload.email, payload.age)

EmailStr and Field(ge=18) reject bad input before your function body even runs, and FastAPI turns the failure into a proper 422 response with a field-level error message — for free. payload here isn't a loose dict you have to defensively unpack; it's a typed object you can trust the moment you're inside the function.

8. Use dependencies for DB-based validation too

The same Depends() pattern that handles auth is also the right place for "does this referenced row actually exist" checks — the kind of validation Pydantic alone can't do, because it needs a database round trip.

Bad — existence check duplicated inside every handler that touches an order:

@app.put("/orders/{order_id}")
async def update_order(order_id: int, payload: OrderUpdate):
    order = db.get_order(order_id)
    if not order:
        raise HTTPException(404, "Order not found")
    return db.update_order(order, payload)

@app.delete("/orders/{order_id}")
async def delete_order(order_id: int):
    order = db.get_order(order_id)
    if not order:
        raise HTTPException(404, "Order not found")
    db.delete_order(order)

Good — the lookup becomes a dependency that every order route can request:

async def get_order_or_404(order_id: int) -> Order:
    order = db.get_order(order_id)
    if not order:
        raise HTTPException(404, "Order not found")
    return order

@app.put("/orders/{order_id}")
async def update_order(payload: OrderUpdate, order: Order = Depends(get_order_or_404)):
    return db.update_order(order, payload)

@app.delete("/orders/{order_id}")
async def delete_order(order: Order = Depends(get_order_or_404)):
    db.delete_order(order)

Every route that touches an order gets a resolved Order object, or the request never reaches the handler at all. That's one function to test, instead of the same if not order check scattered across the codebase and inevitably forgotten in the next new route.

9. Stop opening a new DB connection on every request

Creating a fresh database connection per request is one of the most common performance killers in FastAPI apps — connection setup (TCP handshake, auth, TLS) is expensive, and it doesn't scale with concurrent traffic.

Bad:

@app.get("/products")
async def list_products():
    conn = create_connection(DATABASE_URL)  # new connection every single request
    products = conn.execute("SELECT * FROM products").fetchall()
    conn.close()
    return products

Good — a connection pool, shared across requests, wired through a dependency:

from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker

engine = create_async_engine(DATABASE_URL, pool_size=20, max_overflow=10)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)

async def get_db():
    async with SessionLocal() as session:
        yield session

@app.get("/products")
async def list_products(db: AsyncSession = Depends(get_db)):
    result = await db.execute(select(Product))
    return result.scalars().all()

The engine and pool are created once, at import time. Each request borrows a session from the pool via Depends(get_db) and returns it when the request finishes — including on exceptions, because it's a context manager. Same idea applies to Redis clients, HTTP clients, anything with real connection overhead: create it once at startup (FastAPI's lifespan handler is the right place), hand out a shared instance through Depends.

10. Log like you'll actually need the logs someday

The default is usually either nothing, or print() statements that vanish the moment you need them. Both fail you at 2am when something's on fire.

Bad:

@app.post("/orders")
async def create_order(payload: OrderCreate):
    print(f"Creating order for {payload}")  # gone the moment stdout scrolls
    try:
        order = db.create_order(payload)
    except Exception as e:
        print(f"Error: {e}")  # no traceback, no request context, no severity
        raise HTTPException(500, "Something went wrong")
    return order

Good — structured logging, with context, at the right levels:

import logging

logger = logging.getLogger("app.orders")

@app.post("/orders")
async def create_order(payload: OrderCreate, request: Request):
    logger.info(
        "creating order",
        extra={"user_email": payload.email, "request_id": request.state.request_id},
    )
    try:
        order = db.create_order(payload)
    except Exception:
        logger.exception(
            "order creation failed",
            extra={"request_id": request.state.request_id},
        )
        raise HTTPException(500, "Something went wrong")
    logger.info("order created", extra={"order_id": order.id})
    return order

A few things matter here: logger.exception instead of a bare except/print, which captures the actual traceback; extra={} for structured fields so your log aggregator (Datadog, CloudWatch, whatever) can filter and query instead of grepping raw strings; and a request_id threaded through via middleware so you can trace one request across every log line it touched. Configure the logger once at startup, level driven by environment (DEBUG locally, INFO or WARNING in production) — not scattered print() calls that someone has to remember to remove.

The thread connecting all of this

None of these are exotic. They're the difference between an API that demos well and one that survives real traffic: routes that don't lie about being async, validation pushed to the layer built for it, dependencies doing the repetitive work so handlers stay thin, and infrastructure (connections, logs) set up once instead of reinvented per request. Get those right and most of what's "hard" about running FastAPI in production quietly disappears.

If you're building or fixing up a FastAPI project and want an extra pair of eyes — or just need a developer for the project itself — please don't hesitate to get in touch. I'm always happy to chat.

Have a project in mind?

I'm always open to hearing about interesting backend work.