Text size
Dark mode
RecallRunGuides & benchmarks
Learn to build real systemsGuides•Benchmarks•Experiments•Community
Search the site/Free checklist

Guides/FastAPI

FastAPI: The production pitfalls checklist

Experience is mostly a list of mistakes you no longer make. This part collects thirty that FastAPI teams have paid for in production, grouped by area, each with…

In this post

What this post covers
  1. 1. Nine silent surprises, demonstrated
  2. 2. Async and concurrency
  3. 3. Data and the database
  4. 4. The API contract
  5. 5. Security
  6. 6. Operations and upgrades
  7. 7. The pre-release checklist
  8. Recap

Experience is mostly a list of mistakes you no longer make. This part collects thirty that FastAPI teams have paid for in production, grouped by area, each with the symptom you would actually see and the fix. Nine of the least obvious are demonstrated below on the installed FastAPI, because several of them fail silently. Use the list as a code-review and pre-release checklist.

🛫

A pilot's checklist

Pilots with twenty years of experience still read the checklist before every take-off, because the dangerous mistakes are not the ones you don't know about; they are the ones you know perfectly well and forget once, at the worst moment. A checklist turns experience into a habit anyone on the team can follow.

proxy · LB#22 #30 middleware#20 CORS routing#15 #16 validation#13 #14 #17 dependencies#3 #19 #21 endpoint#1 #2 #4 #6 database#7 – #11 response#12 #18 around the request #5 #25 #26 #27 #28 #29 Numbers match the tables below. "Around the request": background work, lifespan, tests, upgrades, replicas and probes.

1. Nine silent surprises, demonstrated

each line is a pitfall from the tables below (number in front)

import warnings
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Annotated
from fastapi import Depends, FastAPI, Query
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel

# 25: an on_event handler next to a lifespan never runs
ran = []
@asynccontextmanager
async def lifespan(app):
    ran.append("lifespan")
    yield

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    legacy_app = FastAPI(lifespan=lifespan)

    @legacy_app.on_event("startup")
    async def warm_cache():
        ran.append("on_event: warm_cache")
with TestClient(legacy_app):
    pass
print(f"#25 startup handlers that ran: {ran}   (warnings: {len(caught)} DeprecationWarning)")

app = FastAPI()

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

USER = {"id": 1, "email": "[email protected]", "password_hash": "$2b$12$Qx…"}

@app.get("/typed")
def typed() -> UserOut:
    return USER

@app.get("/manual")
def manual() -> UserOut:
    return JSONResponse(USER)                          # 12: bypasses the return type entirely

@app.get("/search")
def search(tags: list[str] = []):                      # 14: without Query() this is a *body* field
    return {"tags": tags}

@app.get("/search-fixed")
def search_fixed(tags: Annotated[list[str], Query()] = []):
    return {"tags": tags}

@app.get("/users/{user_id}")
def get_user(user_id: int):
    return {"id": user_id}

@app.get("/users/me")                                  # 15: declared too late, never reached
def get_me():
    return {"me": True}

@app.post("/orders/")
def create_order():
    return {"created": True}

@app.get("/greet")
def greet(name: str | None):                           # 17: "can be None" is not "optional"
    return {"hello": name}

@app.get("/when")
def when():
    return {"naive": datetime(2026, 9, 19, 10, 0),     # 18: which timezone is this?
            "aware": datetime.fromisoformat("2026-09-19T10:00:00+05:30")}

counter = []
def new_request_number() -> int:
    counter.append(1)
    return len(counter)

@app.get("/numbers")
def numbers(a: Annotated[int, Depends(new_request_number)],
            b: Annotated[int, Depends(new_request_number)],      # 19: cached, same value as a
            c: Annotated[int, Depends(new_request_number, use_cache=False)]):
    return [a, b, c]

class Item(BaseModel):
    name: str

@app.post("/items")
def add_item(item: Item):
    return item

c = TestClient(app)
print("#12 typed return: ", c.get("/typed").json())
print("    JSONResponse: ", c.get("/manual").json())
print("#14 ?tags=a&tags=b ->", c.get("/search?tags=a&tags=b").json(), " with Query():", c.get("/search-fixed?tags=a&tags=b").json())
r = c.get("/users/me")
print("#15 GET /users/me ->", r.status_code, r.json()["detail"][0]["msg"])
r = c.post("/orders", follow_redirects=False)
print("#16 POST /orders  ->", r.status_code, "redirect to", r.headers["location"])
r = c.get("/greet")
print("#17 GET /greet    ->", r.status_code, r.json()["detail"][0]["msg"])
print("#18 datetimes     ->", c.get("/when").json())
print("#19 three Depends ->", c.get("/numbers").json())
r = c.post("/items", content='{"name": "tea"}', headers={"Content-Type": "text/plain"})
print("#27 JSON sent as text/plain ->", r.status_code, r.json()["detail"][0]["msg"][:48] + "…")

Output

#25 startup handlers that ran: ['lifespan']   (warnings: 2 DeprecationWarning)
#12 typed return:  {'id': 1, 'email': '[email protected]'}
    JSONResponse:  {'id': 1, 'email': '[email protected]', 'password_hash': '$2b$12$Qx…'}
#14 ?tags=a&tags=b -> {'tags': []}  with Query(): {'tags': ['a', 'b']}
#15 GET /users/me -> 422 Input should be a valid integer, unable to parse string as an integer
#16 POST /orders  -> 307 redirect to http://testserver/orders/
#17 GET /greet    -> 422 Field required
#18 datetimes     -> {'naive': '2026-09-19T10:00:00', 'aware': '2026-09-19T10:00:00+05:30'}
#19 three Depends -> [1, 1, 2]
#27 JSON sent as text/plain -> 422 Input should be a valid dictionary or object to …

2. Async and concurrency

#MistakeSymptomFix
1A blocking call (time.sleep, requests, a sync DB driver) inside async defevery request on the worker stalls; health checks time out under loaduse async libraries, or make the endpoint def, or run_in_threadpool (part 11)
2CPU work in the event loop: pandas, image resizing, PDF generation, bcrypt (0.44 s each, part 17)latency spikes for everyone during logins or reportsthread pool for short bursts, a process pool or a job queue for heavy work
3Thread pool exhaustion: many slow def endpoints or sync dependenciesrequests queue although CPU is idle; 40 concurrent sync calls max by defaultgo async, or raise the AnyIO limiter deliberately in the lifespan
4A new HTTP client, engine or SDK client per request9× slower calls (part 22), socket exhaustioncreate once in the lifespan, share via state or a dependency
5Fire-and-forget asyncio.create_task without keeping a referencework silently vanishes; exceptions are never loggeda durable queue or an outbox (part 13); at minimum keep tasks in a set and log failures
6No timeouts on outbound callsone slow partner API exhausts every worker; cascading outageexplicit connect and read timeouts, limited retries with backoff, circuit breakers

3. Data and the database

#MistakeSymptomFix
7Lazy-loading a relationship with async SQLAlchemyMissingGreenlet in production paths tests never hitselectinload, and lazy="raise" to catch it early (part 15)
8N+1 queriesan endpoint fine in development takes seconds with real dataeager loading; assert the query count in tests
9A session that outlives its request (global, or handed to a background task)"session is closed", mixed-up data between requestssession per request; background jobs open their own
10A transaction held open around an external API calllock waits, pool exhaustion when the partner is slowcall out first, or record intent in an outbox and commit (part 13)
11Unbounded list endpoints, OFFSET pagination on big tablestimeouts and memory spikes as data growsenforced limits and cursor (keyset) pagination (part 16)

4. The API contract

#MistakeSymptomFix
12Returning raw dicts or ORM objects without a return type, or returning JSONResponse directlythe password hash in the response (demonstrated above)always declare output models; return data, not Response objects, unless you mean it
13One model for input and outputclients set role or owner_id themselves (mass assignment)separate In, Patch and Out models with extra="forbid" (part 18)
14tags: list[str] = [] without Query()query values silently ignored (demonstrated)annotate list query parameters with Query()
15A fixed path declared after a matching path parameter/users/me returns 422 (demonstrated)declare fixed paths first
16Inconsistent trailing slashes307 redirects; behind a proxy without forwarded headers they point to http://one convention, tests for it, correct proxy settings (part 23)
17x: str | None without a default"Field required" for a parameter everyone thought was optionalx: str | None = None
18Naive datetimestimes without an offset; clients guess the zone; off-by-hours bugstimezone-aware UTC everywhere; AwareDatetime in models
19Assuming each Depends call runs separatelya dependency used twice runs once per request (demonstrated)that is usually what you want; use use_cache=False when it is not

5. Security

#MistakeSymptomFix
20CORS allow_origins=["*"] with credentialsany website can make authenticated calls as your usersan explicit origin list (part 12)
21Checking who the user is, but not whether they own the objectchanging an id in the URL shows someone else's data (BOLA)scope every query by owner; authorization-matrix tests (parts 18, 19)
22Trusting X-Forwarded-For from anyoneIP rate limits and allow-lists bypassedtrust only the proxy's network (part 23)
23JWTs decoded without pinned algorithms; tokens or passwords in logsforged tokens; credentials in the log platformalgorithms=["HS256"] (or your key's), audience and expiry checks; redact logs (parts 17, 21)
24No request size limitsa single upload fills memory or disklimits at the proxy and in streaming upload handlers (part 14)

6. Operations and upgrades

#MistakeSymptomFix
25@app.on_event handlers next to a lifespanthe handler silently never runs (demonstrated); caches never warmedmove everything into the lifespan (part 10)
26TestClient(app) without withtests pass without the lifespan, then production differswith TestClient(app) as client in a fixture (part 19)
27Unpinned FastAPI or Starlette upgrades0.132 started rejecting JSON sent with the wrong Content-Type (demonstrated); 0.137 changed app.routes into a treepin versions, read release notes, upgrade deliberately behind tests
28In-process state with several workers or replicasrate limits, caches and WebSocket rooms that work in development but not in productionshared state in Redis or the database (parts 14, 18, 22)
29A liveness probe that checks the databasea database blip restarts every podliveness checks only the process; readiness checks dependencies (part 21)
30Deploying without drainingerrors on every release; random 502s from keep-alive racespreStop sleep, graceful timeout, keep-alive longer than the LB idle timeout (part 23)

7. The pre-release checklist

Code review

Output models on every route? Ownership checks on every object access? No blocking calls in async def? Timeouts on every outbound call? Pagination limits enforced?

Tests

Authorization matrix, validation cases, OpenAPI diff against the last release, migrations tested up and down, and the suite run under the lifespan.

Operations

Dashboards for RED metrics per route, alerts on error ratio and p99, request ids in logs, traces sampled, probes split into liveness and readiness.

Release

Pinned dependencies, immutable image tag, expand/contract migrations, rolling update with draining, and a rollback that has actually been tried.

Recap

  • The most expensive mistakes are silent: ignored parameters, bypassed response models, handlers that never run.
  • Most of the thirty are prevented by three habits: explicit models in and out, a fresh app and lifespan in every test, and measuring before scaling.
  • Upgrades are changes too: pin, read the release notes, and let the test suite decide.

Get the free RAG production checklist

25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.

One email to confirm. Unsubscribe any time. What's in it · Privacy

Want the production version of this? See ShipRAG: A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests.

Written by

Alby Tomy

Python/FastAPI Engineer | Data Engineer | AI Engineer building RAG and MCP systems.

X · GitHub · Contact & enquiries