6.1 KiB
FastAPI Best Practices
!!! info "Primary sources" - FastAPI deployment - FastAPI lifespan events
App Factory Pattern
Always use create_app() — it makes the app testable (inject a test Settings) and avoids module-level side effects.
# src/my_app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from my_app.config import Settings
from my_app.api import health, v1
def create_app(settings: Settings | None = None) -> FastAPI:
if settings is None:
settings = Settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
# --- startup ---
app.state.settings = settings
# Open DB pool, warm caches, etc.
yield
# --- shutdown ---
# Close DB pool, flush buffers, etc.
app = FastAPI(
title=settings.app_name,
version="1.0.0",
lifespan=lifespan,
docs_url="/docs" if settings.debug else None,
redoc_url=None,
)
app.include_router(health.router)
app.include_router(v1.router, prefix="/api/v1")
return app
# Module-level instance for uvicorn
app = create_app()
!!! warning "Prefer lifespan handlers"
Never use @app.on_event("startup") / @app.on_event("shutdown"). These are deprecated. The asynccontextmanager lifespan is the canonical approach since FastAPI 0.95.
Router Organization
src/my_app/api/
├── __init__.py
├── health.py # GET /healthz — no auth, no versioning
├── deps.py # Shared Depends() factories
└── v1/
├── __init__.py # APIRouter with prefix="/v1"
├── items.py
└── users.py
Each router file:
from fastapi import APIRouter
router = APIRouter(prefix="/items", tags=["items"])
@router.get("/")
async def list_items() -> list[Item]:
...
Root registration:
from fastapi import APIRouter
from my_app.api.v1 import items, users
router = APIRouter()
router.include_router(items.router)
router.include_router(users.router)
Pydantic Settings (Configuration)
# src/my_app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "My App"
debug: bool = False
host: str = "0.0.0.0"
port: int = 8000
log_level: str = "info"
# Add DB URL, secret keys, etc. here — never hardcode
# database_url: str # required — will raise if missing
@lru_cache
def get_settings() -> Settings:
return Settings()
Use lru_cache so the .env file is read once. In tests, override with:
from my_app.config import get_settings
from my_app.main import create_app
app = create_app(settings=Settings(debug=True, database_url="sqlite://"))
Never import settings as a module-level singleton — it prevents test overrides.
Health Endpoint
# src/my_app/api/health.py
from fastapi import APIRouter
from fastapi.responses import JSONResponse
router = APIRouter(tags=["ops"])
@router.get("/healthz", include_in_schema=False)
async def health() -> JSONResponse:
"""Kubernetes/Docker liveness probe. No auth. No versioning."""
return JSONResponse({"status": "ok"})
@router.get("/readyz", include_in_schema=False)
async def readiness(request: Request) -> JSONResponse:
"""Readiness probe — check DB connectivity, etc."""
# Example: await request.app.state.db.execute("SELECT 1")
return JSONResponse({"status": "ready"})
Rules:
- No authentication required on
/healthzand/readyz. /healthz— liveness: can the process respond?/readyz— readiness: are dependencies available?- Keep them on the root path (not
/api/v1/healthz).
Dependency Injection
Use Depends() to share resources from app state:
# src/my_app/api/deps.py
from fastapi import Depends, Request
from my_app.config import Settings
def get_settings(request: Request) -> Settings:
return request.app.state.settings
SettingsDep = Annotated[Settings, Depends(get_settings)]
In route handlers:
@router.get("/config")
async def show_config(settings: SettingsDep) -> dict:
return {"debug": settings.debug}
Error Handling
Register a global exception handler for unhandled errors:
from fastapi import Request
from fastapi.responses import JSONResponse
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
# Log the exception — never expose internal details to clients
logger.exception("Unhandled error", exc_info=exc)
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
CORS
Add CORS middleware only when needed (e.g., browser clients from a different origin):
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins, # list from config, never ["*"] in prod
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Response Models
Always declare response_model or return type annotations — FastAPI uses them for OpenAPI docs and response validation:
@router.post("/items/", response_model=ItemOut, status_code=201)
async def create_item(item: ItemIn) -> ItemOut:
...
Use separate In / Out models when the write shape differs from the read shape (e.g., password hashing, computed fields).
Security Checklist
- Never expose
docs_urlin production (setdocs_url=Nonewhennot settings.debug). - Validate all user input with Pydantic models — never pass raw request data to DB queries.
- Use
SecretStrfor passwords and API keys inSettings. - Apply authentication globally via middleware or
app.include_router(..., dependencies=[Depends(verify_token)]). - Add
X-Content-Type-Options: nosniffandX-Frame-Options: DENYheaders in production middleware.