FastAPI earned its popularity honestly: Pydantic models give you validation and serialisation from Python type hints, the async stack is genuinely fast, and you get interactive OpenAPI docs for free. Here is how I structure a FastAPI service that is ready for production, not just a tutorial.
Pydantic models as the contract
Everything starts with the models. Request and response schemas are Pydantic classes; FastAPI validates inbound JSON against them and documents them automatically. Keep separate models for input and output so you never leak internal fields.
from datetime import date
from pydantic import BaseModel, Field
from uuid import UUID
class BookingIn(BaseModel):
guest_id: UUID
room_id: str
check_in: date
check_out: date
class BookingOut(BaseModel):
id: UUID
status: str
# internal columns (cost, margin) deliberately absent from the responsefrom fastapi import FastAPI, status
app = FastAPI(title="Bookings API")
@app.post("/bookings", response_model=BookingOut, status_code=status.HTTP_201_CREATED)
async def create_booking(payload: BookingIn) -> BookingOut:
booking = await bookings.create(payload)
return booking # filtered through BookingOut automaticallyresponse_model is doing real work: it filters the returned object down to exactly the declared fields. Return a full ORM object and FastAPI serialises only what BookingOut declares — your leak-prevention is the type annotation.
Dependency injection: the heart of FastAPI
FastAPI's Depends system is how you keep handlers thin. A dependency is any callable; FastAPI resolves it per request, caches it within the request, and injects the result. Use it for the DB session, the current user, pagination — anything cross-cutting.
from fastapi import Depends, HTTPException
from typing import Annotated
async def get_db():
async with SessionLocal() as session:
yield session # cleanup runs after the response is sent
async def current_user(
token: Annotated[str, Depends(oauth2_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
user = await users.from_token(db, token)
if user is None:
raise HTTPException(status_code=401, detail="Invalid credentials")
return user
@app.get("/me", response_model=UserOut)
async def me(user: Annotated[User, Depends(current_user)]) -> User:
return userTyped settings with pydantic-settings
Read configuration once, validated, from the environment — never scatter os.getenv across the codebase. pydantic-settings validates your config at startup, so a missing or malformed env var fails fast and loudly instead of at the first request.
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
database_url: str
jwt_secret: str
debug: bool = False
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> Settings:
return Settings() # cached — read and validated onceConsistent error handling
Register exception handlers so every error leaves the service in the same shape. A domain exception becomes a clean JSON body with the right status code, and you never let a raw stack trace reach a client.
from fastapi import Request
from fastapi.responses import JSONResponse
class DomainError(Exception):
def __init__(self, message: str, status: int = 400):
self.message, self.status = message, status
@app.exception_handler(DomainError)
async def domain_error_handler(_: Request, exc: DomainError):
return JSONResponse(status_code=exc.status, content={"error": exc.message})Run FastAPI under Uvicorn with multiple workers behind a process manager (Gunicorn with the uvicorn worker class) in production. A single async worker is fast, but CPU-bound work still blocks the event loop — scale with workers, and push heavy work to a task queue.
FastAPI's whole philosophy is that your type annotations ARE your API contract — validation, serialisation, docs and injection all flow from them. Lean into that and the framework does an enormous amount of the tedious work correctly, leaving you to write the business logic.