ArchitectureStaff
Staff Prep 17: FastAPI Internals — Routing, DI, and Performance Tuning
April 4, 20269 min readPART 15 / 18
Back to Part 16: asyncio Deep Dive. FastAPI is a relatively thin layer on top of Starlette. It adds automatic OpenAPI generation, Pydantic validation, and a powerful dependency injection system. Understanding what each layer does — and what each costs — helps you make the right performance trade-offs and debug issues that span multiple layers.
FastAPI's layered architecture
Client HTTP Request
│
▼
Uvicorn (ASGI server) — TCP, HTTP parsing
│
▼
Starlette (routing, middleware, request/response)
│
▼
FastAPI (Pydantic validation, DI, OpenAPI generation)
│
▼
Your route handler function
FastAPI adds overhead on top of bare Starlette. For most workloads, this overhead is negligible (microseconds). For ultra-high-throughput endpoints (>100k req/s), the validation cost matters.
Router organisation at scale
python
from fastapi import FastAPI, APIRouter, Depends
# api/routers/users.py
router = APIRouter(
prefix="/users",
tags=["users"],
dependencies=[Depends(verify_token)], # applied to all routes in this router
)
@router.get("/{user_id}")
async def get_user(user_id: int, db=Depends(get_db)):
return await db.get(User, user_id)
@router.post("/", status_code=201)
async def create_user(user: UserCreate, db=Depends(get_db)):
return await db.create(User, user.dict())
# api/routers/orders.py
orders_router = APIRouter(prefix="/orders", tags=["orders"])
# main.py
app = FastAPI()
app.include_router(users.router, prefix="/v1")
app.include_router(orders.orders_router, prefix="/v1")
# All routes: /v1/users/{id}, /v1/orders, etc.
Dependency injection: scoping and caching
python
from fastapi import Depends
# Default: cached per request — same instance reused
async def get_db():
async with AsyncSessionLocal() as session:
yield session
# use_cache=False: new instance per injection point
# (rarely needed, but useful for testing isolation)
async def get_uncached_db(db: AsyncSession = Depends(get_db, use_cache=False)):
return db
# Dependency with its own state: shared across requests
# Pattern: singleton dependency via module-level state
class DatabaseService:
def __init__(self):
self.pool = None
async def startup(self):
self.pool = await asyncpg.create_pool("postgresql://...")
db_service = DatabaseService()
@app.on_event("startup")
async def startup():
await db_service.startup()
def get_db_service() -> DatabaseService:
return db_service # returns the same singleton
@app.get("/health")
async def health(db: DatabaseService = Depends(get_db_service)):
await db.pool.fetchval("SELECT 1")
return {"ok": True}
Middleware vs dependencies: choosing the right layer
python
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Request
import uuid
import time
# Use Middleware for:
# - Cross-cutting concerns (logging, timing, CORS, auth headers)
# - Things that apply to ALL routes including static files
# - Request/response transformation at the bytes level
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
request_id = str(uuid.uuid4())
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
# Use Dependencies for:
# - Business logic setup (DB sessions, current user, permissions)
# - Things that should only run for specific routes or routers
# - Things that need to be testable and mockable in isolation
async def get_current_user(token: str = Depends(get_token), db=Depends(get_db)):
# Only runs for routes that declare this dependency
# Can be easily mocked in tests
return await verify_and_fetch_user(token, db)
# Key difference: middleware cannot access route handler return values easily
# Dependencies can see the full request context but not raw bytes
Response model: validation and performance
python
from pydantic import BaseModel
from typing import Optional
class UserResponse(BaseModel):
id: int
name: str
email: str
# NOTE: password, internal_flags NOT in response model
# FastAPI filters these out automatically
@app.get("/users/{id}", response_model=UserResponse)
async def get_user(id: int, db=Depends(get_db)):
user = await db.get(User, id)
return user # FastAPI serialises through UserResponse, strips excluded fields
# Performance: response_model validation adds overhead
# For hot paths returning large datasets:
@app.get("/users/bulk", response_model=None) # skip validation
async def get_users_bulk(db=Depends(get_db)):
users = await db.execute("SELECT id, name, email FROM users LIMIT 1000")
from fastapi.responses import ORJSONResponse
return ORJSONResponse(content=[dict(u) for u in users.fetchall()])
# ORJSONResponse is 2-3x faster than default JSONResponse for large payloads
Streaming responses for large data
python
from fastapi.responses import StreamingResponse
import asyncio
# Stream large CSV export without loading everything into memory
async def generate_csv_stream(query_params: dict):
yield "id,name,email
" # header
offset = 0
batch_size = 1000
while True:
rows = await db.execute(
"SELECT id, name, email FROM users LIMIT $1 OFFSET $2",
batch_size, offset
)
batch = rows.fetchall()
if not batch:
break
for row in batch:
yield f"{row['id']},{row['name']},{row['email']}
"
offset += batch_size
await asyncio.sleep(0) # yield to event loop
@app.get("/users/export.csv")
async def export_users():
return StreamingResponse(
generate_csv_stream({}),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=users.csv"}
)
Custom exception handlers and error shapes
python
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"error": "validation_failed",
"fields": [
{"path": " -> ".join(str(x) for x in err["loc"]),
"message": err["msg"]}
for err in exc.errors()
]
}
)
class NotFoundError(Exception):
def __init__(self, resource: str, id: int):
self.resource = resource
self.id = id
@app.exception_handler(NotFoundError)
async def not_found_handler(request: Request, exc: NotFoundError):
return JSONResponse(
status_code=404,
content={"error": f"{exc.resource} with id {exc.id} not found"}
)
@app.get("/orders/{id}")
async def get_order(id: int, db=Depends(get_db)):
order = await db.get(Order, id)
if not order:
raise NotFoundError("Order", id)
return order
Quiz: test your understanding
Before moving on, answer these in your head (or out loud):
- What is the difference between adding auth via middleware vs a dependency? Give a scenario where each is the better choice.
- Two routes both declare
Depends(get_db)in the same request handler chain. How many DB sessions are created? What if you useuse_cache=False? - You need to return a 500MB CSV file from a FastAPI endpoint. What goes wrong if you
return contentas a single string? How do you fix it? - What does
response_model=UserResponsedo in terms of security? What happens if your ORM model has ahashed_passwordfield not in the response model? - Where in the middleware stack does Pydantic request validation happen? Before middleware or after?
Next up — Part 18: Task Queues & Celery. Broker vs backend, worker architecture, prefetch, and idempotency patterns.
Share this