Fastapi best practices
Use when building or reviewing FastAPI applications — async route design, asynccontextmanager lifespan for DB/Qdrant/Redis startup, Pydantic v2 with model_config and model_validator, Annotated+Depends injection, JWT with OAuth2PasswordBearer, structured AppException hierarchy, CORS, background tasks, and OpenAPI config. Python 3.11+, pydantic>=2.0.From its SKILL.md
npx -y skills add Malikasadjaved/skills --skill fastapi-best-practicesAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
23.1 KB, ~5.3k tokens by cl100k_base, as published. Nobody here has run it
Project Structure
app/
├── main.py # FastAPI() create, lifespan, include routers, middleware
├── core/
│ ├── config.py # pydantic-settings BaseSettings (reads .env)
│ ├── security.py # JWT encode/decode, password hashing
│ └── exceptions.py # Custom AppException hierarchy
├── models/
│ ├── request.py # Pydantic v2 request schemas (inbound)
│ └── response.py # Pydantic v2 response schemas (outbound)
├── dependencies/
│ ├── database.py # get_db (async session yield)
│ ├── auth.py # get_current_user (JWT decode)
│ └── services.py # get_qdrant_client, get_redis, get_http_client
├── routers/
│ ├── health.py # /health, /readyz
│ ├── auth.py # /auth/login, /auth/refresh
│ └── v1/ # Versioned API
│ ├── __init__.py
│ └── documents.py
├── services/ # Business logic (no HTTP concerns)
│ ├── document_service.py
│ └── notification_service.py
└── middleware/
├── request_id.py # X-Request-ID injection
└── timing.py # X-Response-Time header
Keep routers/ thin (parse request, call service, return response). Put
business logic in services/. Never import Request or Response in
service layer modules.
Lifespan Pattern
Use asynccontextmanager lifespan. Never use the deprecated @app.on_event("startup")
or @app.on_event("shutdown") — they are removed in FastAPI 0.112+.
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# ── STARTUP ──────────────────────────────────────────
# Init resources BEFORE yield — failures here prevent app start
pool = await create_db_pool(app.state.settings.database_url)
app.state.db_pool = pool
qdrant = QdrantClient(url=app.state.settings.qdrant_url)
app.state.qdrant = qdrant
print(f"[lifespan] Ready — {app.state.settings.app_name}")
yield # ← app runs here
# ── SHUTDOWN ─────────────────────────────────────────
# Cleanup AFTER yield — runs on SIGTERM/SIGINT
await pool.close()
qdrant.close()
print("[lifespan] Shutdown complete")
app = FastAPI(lifespan=lifespan)
Store shared resources on request.app.state:
# In a dependency or route
def get_qdrant(request: Request) -> QdrantClient:
return request.app.state.qdrant
If a startup resource is optional (e.g., Qdrant not running locally), wrap its init in try/except and store None — then guard in the dependency.
Pydantic v2 Models
BaseModel with model_config
from pydantic import BaseModel, Field
from datetime import datetime
class DocumentCreate(BaseModel):
model_config = {"str_strip_whitespace": True, "extra": "forbid"}
title: str = Field(min_length=1, max_length=200)
content: str = Field(min_length=1)
domain: str = Field(pattern=r"^(finance|hr|legal|shared)$")
tags: list[str] = Field(default_factory=list, max_length=10)
class DocumentResponse(BaseModel):
model_config = {"from_attributes": True} # enables ORM → Pydantic
id: str
title: str
domain: str
created_at: datetime
snippet: str = Field(exclude=True) # present in code, excluded from JSON
computed_field
from pydantic import BaseModel, computed_field
class UserResponse(BaseModel):
model_config = {"from_attributes": True}
first_name: str
last_name: str
@computed_field
def display_name(self) -> str:
return f"{self.first_name} {self.last_name}"
@computed_field
def initials(self) -> str:
return f"{self.first_name[0]}{self.last_name[0]}".upper()
field_validator
from pydantic import BaseModel, field_validator
class SearchQuery(BaseModel):
query: str
top_k: int = 5
@field_validator("top_k")
@classmethod
def clamp_top_k(cls, v: int) -> int:
return max(1, min(50, v))
@field_validator("query")
@classmethod
def strip_query(cls, v: str) -> str:
stripped = v.strip()
if not stripped:
raise ValueError("query must not be empty")
return stripped
model_validator (cross-field)
from pydantic import BaseModel, model_validator
class DateRange(BaseModel):
start_date: str
end_date: str
@model_validator(mode="after")
def check_date_order(self):
if self.start_date > self.end_date:
raise ValueError("start_date must be <= end_date")
return self
Request vs Response separation
Always separate inbound from outbound models — even if they look identical today:
# models/request.py
class UserCreate(BaseModel):
email: str
password: str = Field(min_length=8)
# models/response.py
class UserPublic(BaseModel):
id: str
email: str
# Never include password, hashed_password, internal flags
This prevents accidental password leaks and decouples API contract from internal schema changes.
Dependency Injection
Use Annotated + Depends (Python 3.11+ preferred style):
from typing import Annotated
from fastapi import Depends, Request
# ── DB session (yields for cleanup) ──────────────────────
async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
async with request.app.state.db_pool.session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
# ── Current user from JWT ────────────────────────────────
async def get_current_user(
request: Request,
token: Annotated[str, Depends(oauth2_scheme)],
) -> User:
try:
payload = decode_jwt(token, request.app.state.settings.jwt_secret)
user_id = payload["sub"]
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
user = await fetch_user(request.app.state.db_pool, user_id)
if user is None:
raise HTTPException(status_code=401, detail="User not found")
return user
# ── Qdrant client ────────────────────────────────────────
def get_qdrant(request: Request) -> QdrantClient:
client = request.app.state.qdrant
if client is None:
raise HTTPException(status_code=503, detail="Qdrant not available")
return client
# ── Shortcut types ───────────────────────────────────────
CurrentUser = Annotated[User, Depends(get_current_user)]
Database = Annotated[AsyncSession, Depends(get_db)]
Qdrant = Annotated[QdrantClient, Depends(get_qdrant)]
# ── Route usage ──────────────────────────────────────────
@app.get("/api/documents")
async def list_documents(
db: Database,
user: CurrentUser,
qdrant: Qdrant,
):
...
The Annotated shortcut types go in dependencies/__init__.py so every router
imports them cleanly.
Router Organization
# routers/v1/documents.py
from fastapi import APIRouter
from dependencies import Database, CurrentUser, Qdrant
router = APIRouter(
prefix="/api/v1/documents",
tags=["v1 — Documents"],
dependencies=[], # router-level deps (auth guard etc.)
)
@router.get("", response_model=list[DocumentResponse])
async def list_docs(
db: Database,
user: CurrentUser,
domain: str | None = None,
):
"""List documents, optionally filtered by domain."""
...
@router.post("", response_model=DocumentResponse, status_code=201)
async def create_doc(
body: DocumentCreate,
db: Database,
user: CurrentUser,
):
...
Include in main app:
# main.py
from routers.v1.documents import router as docs_router
app.include_router(docs_router)
Use dependencies=[Depends(get_current_user)] on the router itself when every
route in that router requires auth — cleaner than repeating it on every endpoint.
Async Route Patterns
When async def vs def
async def: when your route awaits something (DB query, HTTP call, Redis). FastAPI runs these on the event loop — zero overhead.def: when your route is pure CPU work with no await. FastAPI runs it in a threadpool so the event loop isn't blocked. Use sparingly — if you need a threadpool explicitly, userun_in_executorinstead.
Async DB query
@app.get("/api/documents/{doc_id}")
async def get_document(doc_id: str, db: Database) -> DocumentResponse:
result = await db.execute(
select(Document).where(Document.id == doc_id)
)
doc = result.scalar_one_or_none()
if doc is None:
raise HTTPException(status_code=404, detail="Document not found")
return DocumentResponse.model_validate(doc)
Async HTTP call with httpx
import httpx
@app.post("/api/chat")
async def proxy_to_whatsapp(body: ChatRequest, request: Request):
async with httpx.AsyncClient() as client:
response = await client.post(
f"{request.app.state.settings.whatsapp_bot_url}/chat",
json={"message": body.message, "chat_jid": body.chat_jid},
headers={"Authorization": f"Bearer {request.app.state.settings.chat_secret}"},
timeout=30.0,
)
response.raise_for_status()
return response.json()
Sync code in threadpool
import asyncio
@app.post("/api/reports")
async def generate_report(body: ReportRequest):
# generate_pdf() is CPU-heavy and synchronous — run it off the event loop
loop = asyncio.get_running_loop()
pdf_bytes = await loop.run_in_executor(None, generate_pdf, body.template, body.data)
return Response(content=pdf_bytes, media_type="application/pdf")
Global Error Handling
Custom exception hierarchy
# core/exceptions.py
class AppException(Exception):
"""Base for all application-level exceptions."""
def __init__(self, detail: str, status_code: int = 400):
self.detail = detail
self.status_code = status_code
class NotFoundError(AppException):
def __init__(self, detail: str = "Resource not found"):
super().__init__(detail=detail, status_code=404)
class ForbiddenError(AppException):
def __init__(self, detail: str = "Access denied"):
super().__init__(detail=detail, status_code=403)
class ServiceUnavailableError(AppException):
def __init__(self, detail: str = "Service unavailable"):
super().__init__(detail=detail, status_code=503)
Exception handlers
# main.py
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from core.exceptions import AppException
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={
"status": "error",
"error": exc.detail,
"type": type(exc).__name__,
},
)
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
# Flatten Pydantic errors into a readable list
errors = []
for error in exc.errors():
errors.append({
"field": " → ".join(str(loc) for loc in error["loc"]),
"message": error["msg"],
})
return JSONResponse(
status_code=422,
content={
"status": "error",
"error": "Validation error",
"details": errors,
},
)
@app.exception_handler(Exception)
async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
# Log the full traceback but return a sanitized error to the client
import logging
logger = logging.getLogger("app")
logger.exception(f"Unhandled error on {request.method} {request.url.path}")
return JSONResponse(
status_code=500,
content={
"status": "error",
"error": "Internal server error",
},
)
Structured error convention
Every error response follows this shape:
{"status": "error", "error": "Human-readable message", "type": "ExceptionClassName"}
This is the same convention used by the project's MCP servers ({"status": "error", "error": str(e)}).
Consistency across the API and MCP layer simplifies client error handling.
Background Tasks
Use BackgroundTasks for fire-and-forget work that doesn't need to block the
HTTP response:
from fastapi import BackgroundTasks
async def send_whatsapp_notification(jid: str, message: str):
"""Simulate sending a WhatsApp message (fire-and-forget)."""
import asyncio
await asyncio.sleep(0.5) # I/O boundary
print(f"[notify] Sent to {jid}: {message}")
@app.post("/api/documents", status_code=201)
async def create_document(
body: DocumentCreate,
db: Database,
user: CurrentUser,
background_tasks: BackgroundTasks,
):
doc = await document_service.create(db, body)
# Fire-and-forget: notification won't delay the 201 response
background_tasks.add_task(
send_whatsapp_notification,
jid=user.whatsapp_jid,
message=f"New document created: {doc.title}",
)
return DocumentResponse.model_validate(doc)
When to use BackgroundTasks:
- Sending push notifications, emails, Slack messages
- Writing to an audit log that's not part of the critical path
- Cache invalidation that can tolerate a few seconds of staleness
When NOT to use BackgroundTasks:
- Work that must complete before the response (use
awaitdirectly) - Long-running jobs (use a task queue: Celery, Redis Queue, or a scheduled DB table)
- Work where failure must be surfaced to the caller (BG tasks swallow exceptions)
Middleware
Request ID injection
# middleware/request_id.py
import uuid
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Request
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("X-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
# main.py
app.add_middleware(RequestIDMiddleware)
Timing middleware
# middleware/timing.py
import time
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Request
class TimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
elapsed_ms = (time.perf_counter() - start) * 1000
response.headers["X-Response-Time"] = f"{elapsed_ms:.1f}ms"
return response
CORS
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "https://app.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
)
Never use allow_origins=["*"] in production. List allowed origins explicitly.
If you need wildcard support, use allow_origin_regex with a constrained pattern.
JWT Auth Pattern
Token creation and verification
# core/security.py
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(subject: str, expires_delta: timedelta | None = None) -> str:
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode = {"sub": subject, "exp": expire, "iat": datetime.now(timezone.utc)}
return jwt.encode(to_encode, settings.jwt_secret, algorithm=ALGORITHM)
def decode_token(token: str) -> dict:
return jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
OAuth2 dependency
# dependencies/auth.py
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
async def get_current_user(
request: Request,
token: Annotated[str | None, Depends(oauth2_scheme)],
) -> User | None:
if token is None:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
payload = decode_token(token)
except JWTError:
raise HTTPException(status_code=401, detail="Invalid or expired token")
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid token payload")
...
Role-based access shortcut
from typing import Annotated
from fastapi import Depends
def require_role(*roles: str):
"""Factory: create a dependency that checks for specific roles."""
async def checker(user: CurrentUser) -> User:
if user.role not in roles:
raise HTTPException(status_code=403, detail=f"Requires role: {roles}")
return user
return Depends(checker)
AdminUser = Annotated[User, Depends(require_role("admin", "superadmin"))]
@app.delete("/api/users/{user_id}")
async def delete_user(user_id: str, admin: AdminUser):
...
OpenAPI Config
app = FastAPI(
title="Digital FTE API",
version="2.0.0",
description="Multi-vertical AI Employee platform — HTTP API",
docs_url="/docs", # Swagger UI
redoc_url="/redoc", # ReDoc
openapi_url="/openapi.json",
servers=[
{"url": "http://localhost:8080", "description": "Local dev"},
{"url": "https://api.digitalfte.com", "description": "Production"},
],
)
Hide internal routes
@app.get("/health", include_in_schema=False)
async def health():
return {"status": "ok"}
@app.get("/internal/metrics", include_in_schema=False)
async def metrics():
...
Use include_in_schema=False for health checks, metrics, debug endpoints, and
internal admin routes — they don't belong in the public API contract.
Response model by status code
@app.post(
"/api/documents",
response_model=DocumentResponse,
status_code=201,
responses={
201: {"description": "Created"},
409: {"description": "Document already exists"},
422: {"description": "Validation error"},
},
)
Environment & Settings
pydantic-settings BaseSettings
# core/config.py
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# App
app_name: str = "Digital FTE API"
debug: bool = False
# Server
host: str = "0.0.0.0"
port: int = 8080
# Database
database_url: str = "postgresql+asyncpg://user:pass@localhost:5432/db"
# Qdrant
qdrant_url: str = "http://localhost:6333"
qdrant_api_key: str = ""
# Auth
jwt_secret: str = "change-me-in-production"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 30
# CORS
allowed_origins: list[str] = ["http://localhost:5173"]
@lru_cache
def get_settings() -> Settings:
return Settings()
lru_cache ensures a single Settings instance — .env is parsed once at
first access, never re-read on every import.
Usage:
settings = get_settings()
# In lifespan:
app.state.settings = settings
# In dependencies/routes:
settings = request.app.state.settings
Common Pitfalls
-
Blocking calls in async routes.
time.sleep(2)orrequests.get(url)block the event loop — all concurrent requests stall. Useawait asyncio.sleep(2)andhttpx.AsyncClientinstead. If you must call sync code, wrap it inawait loop.run_in_executor(None, sync_func). -
Missing
awaiton async functions. Calling an async function withoutawaitreturns a coroutine object that never executes — no error, just silent failure. Type checkers catch this; if you're not using mypy/pyright, start. -
Wrong status codes.
200for every success (use201for creation),400for validation (use422per RFC 9110 for semantic validation failures).401for missing/expired auth,403for insufficient permissions — don't conflate them. -
Pydantic v1 → v2 migration traps.
regex=is nowpattern=,orm_modeis nowfrom_attributes=True,@validatoris now@field_validator,schema()is nowmodel_json_schema(),.dict()is now.model_dump(). -
Returning ORM objects without serialization. FastAPI will try to JSON-encode the ORM object directly — SQLAlchemy objects aren't serializable. Always
return ResponseModel.model_validate(orm_obj)or setresponse_model=on the route decorator. -
Using
@app.on_event. These decorators are deprecated and removed in FastAPI 0.112+. Uselifespan(asynccontextmanager) for all startup/shutdown logic. -
Creating a new client per request.
httpx.AsyncClient,QdrantClient, andredis.asyncio.Redisall manage connection pools. Create them once in lifespan, store onapp.state, and inject viaDepends. Per-request creation adds ~50ms overhead and leaks sockets under load. -
No timeout on external calls.
httpx.get(url)withouttimeout=hangs indefinitely if the downstream service is slow — the event loop stalls, and your health check starts failing. Always set an explicit timeout.
Install
pip install fastapi>=0.111.0 pydantic>=2.0 pydantic-settings python-jose[cryptography] httpx python-dotenv uvicorn[standard]
What ships with it: 4 files
36.0 KB alongside SKILL.md, 3 of them executable
references/
- fastapi-cheatsheet.md6.4 KB
scripts/
- app_template.pyruns10.0 KB
- dependencies.pyruns10.5 KB
- error_handlers.pyruns9.1 KB