Building production-ready APIs requires more than just knowing the framework—it demands battle-tested patterns and conventions that scale. This guide compiles the most effective FastAPI best practices refined over years of production experience.
Source: This guide is inspired by zhanymkanov/fastapi-best-practices, an excellent community resource for FastAPI development.
Project Structure
The best structure is consistent, straightforward, and free of surprises. While many tutorials divide projects by file type (e.g., crud, routers, models), a domain-driven approach scales better for larger applications:
fastapi-project
├── alembic/
├── src
│ ├── auth
│ │ ├── router.py # Core endpoints
│ │ ├── schemas.py # Pydantic models
│ │ ├── models.py # DB models
│ │ ├── dependencies.py # Router dependencies
│ │ ├── config.py # Local configs
│ │ ├── constants.py # Error codes, constants
│ │ ├── exceptions.py # Custom exceptions
│ │ ├── service.py # Business logic
│ │ └── utils.py # Helper functions
│ ├── posts
│ │ └── ... (same structure)
│ ├── config.py # Global configs
│ ├── models.py # Global models
│ ├── exceptions.py # Global exceptions
│ ├── database.py # DB connection
│ └── main.py # App entry point
├── tests/
│ ├── auth/
│ └── posts/
└── requirements/
├── base.txt
├── dev.txt
└── prod.txtKey Principles
- Domain-first organization: Each feature module contains all related code
- Explicit imports: When importing from other packages, use explicit module names:
from src.auth import constants as auth_constants
from src.notifications import service as notification_service
from src.posts.constants import ErrorCode as PostsErrorCodeUnderstanding Async Routes
FastAPI is built for async operations, but understanding when to use async vs sync is crucial.
I/O Intensive Tasks
import asyncio
import time
from fastapi import APIRouter
router = APIRouter()
# ❌ BAD: Blocks the entire event loop
@router.get("/terrible-ping")
async def terrible_ping():
time.sleep(10) # Blocks everything for 10 seconds
return {"pong": True}
# ✅ GOOD: Runs in threadpool, doesn't block event loop
@router.get("/good-ping")
def good_ping():
time.sleep(10) # Runs in separate thread
return {"pong": True}
# ✅ BEST: Non-blocking async operation
@router.get("/perfect-ping")
async def perfect_ping():
await asyncio.sleep(10) # Non-blocking
return {"pong": True}Key Insight: FastAPI runs
syncroutes in a threadpool automatically, keeping the event loop free. But threads have overhead and limited pool size—prefer async operations when possible.
CPU Intensive Tasks
Awaiting CPU-bound tasks (calculations, data processing) is counterproductive:
- CPU tasks require processing power—they can't be "awaited"
- Thread pools don't help due to Python's GIL
- Solution: Use process pools to offload CPU work to separate processes
Pydantic Best Practices
Use Pydantic's Full Power
Pydantic offers comprehensive validation—leverage it:
from enum import Enum
from pydantic import AnyUrl, BaseModel, EmailStr, Field
class MusicBand(str, Enum):
AEROSMITH = "AEROSMITH"
QUEEN = "QUEEN"
ACDC = "AC/DC"
class UserBase(BaseModel):
first_name: str = Field(min_length=1, max_length=128)
username: str = Field(min_length=1, max_length=128, pattern="^[A-Za-z0-9-_]+$")
email: EmailStr
age: int = Field(ge=18, default=None)
favorite_band: MusicBand | None = None
website: AnyUrl | None = NoneCustom Base Model
Create a global base model to standardize behavior across all schemas:
from datetime import datetime
from zoneinfo import ZoneInfo
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, ConfigDict
def datetime_to_gmt_str(dt: datetime) -> str:
if not dt.tzinfo:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
return dt.strftime("%Y-%m-%dT%H:%M:%S%z")
class CustomModel(BaseModel):
model_config = ConfigDict(
json_encoders={datetime: datetime_to_gmt_str},
populate_by_name=True,
)
def serializable_dict(self, **kwargs):
"""Return a dict with only serializable fields."""
return jsonable_encoder(self.model_dump())Decouple BaseSettings
Split configuration across modules for maintainability:
# src/auth/config.py
from pydantic_settings import BaseSettings
class AuthConfig(BaseSettings):
JWT_ALG: str
JWT_SECRET: str
JWT_EXP: int = 5 # minutes
auth_settings = AuthConfig()
# src/config.py
from pydantic import PostgresDsn, RedisDsn
from pydantic_settings import BaseSettings
class Config(BaseSettings):
DATABASE_URL: PostgresDsn
REDIS_URL: RedisDsn
ENVIRONMENT: str = "production"
settings = Config()Dependency Injection Patterns
Beyond Simple Injection
Use dependencies for request validation, not just injection:
# dependencies.py
async def valid_post_id(post_id: UUID4) -> dict[str, Any]:
post = await service.get_by_id(post_id)
if not post:
raise PostNotFound()
return post
# router.py
@router.get("/posts/{post_id}")
async def get_post(post: dict[str, Any] = Depends(valid_post_id)):
return post
@router.put("/posts/{post_id}")
async def update_post(
update_data: PostUpdate,
post: dict[str, Any] = Depends(valid_post_id),
):
return await service.update(id=post["id"], data=update_data)Chain Dependencies
Build complex validation from simple, reusable parts:
async def parse_jwt_data(
token: str = Depends(OAuth2PasswordBearer(tokenUrl="/auth/token"))
) -> dict[str, Any]:
try:
payload = jwt.decode(token, "JWT_SECRET", algorithms=["HS256"])
except JWTError:
raise InvalidCredentials()
return {"user_id": payload["id"]}
async def valid_owned_post(
post: dict[str, Any] = Depends(valid_post_id),
token_data: dict[str, Any] = Depends(parse_jwt_data),
) -> dict[str, Any]:
if post["creator_id"] != token_data["user_id"]:
raise UserNotOwner()
return postDependency Caching
FastAPI caches dependency results per request—use this to your advantage:
# parse_jwt_data is called only ONCE, even if used in multiple places
@router.get("/users/{user_id}/posts/{post_id}")
async def get_user_post(
post: Mapping = Depends(valid_owned_post), # Uses parse_jwt_data
user: Mapping = Depends(valid_active_creator), # Also uses parse_jwt_data
):
return postPrefer Async Dependencies
Sync dependencies run in the threadpool—avoid unnecessary overhead:
# ❌ Runs in threadpool (overhead)
def get_current_user_sync(token: str = Depends(oauth2_scheme)):
return decode_token(token)
# ✅ No threadpool overhead
async def get_current_user_async(token: str = Depends(oauth2_scheme)):
return decode_token(token)RESTful Design Tips
Consistent Path Variables
Enable dependency reuse by maintaining consistent naming:
# src/profiles/dependencies.py
async def valid_profile_id(profile_id: UUID4) -> Mapping:
profile = await service.get_by_id(profile_id)
if not profile:
raise ProfileNotFound()
return profile
# src/creators/dependencies.py
async def valid_creator_id(profile: Mapping = Depends(valid_profile_id)) -> Mapping:
if not profile["is_creator"]:
raise ProfileNotCreator()
return profile
# Use profile_id in both routes (not creator_id)
@router.get("/creators/{profile_id}")
async def get_creator(creator: Mapping = Depends(valid_creator_id)):
return creatorResponse Serialization
Understanding Double Validation
FastAPI validates responses twice—be aware of this overhead:
@app.get("/", response_model=ProfileResponse)
async def root():
return ProfileResponse() # Created once here
# FastAPI creates it AGAIN to validate against response_modelFor performance-critical endpoints, consider returning dicts directly or using Response with pre-serialized JSON.
Use Sync SDKs in Thread Pools
When you must use synchronous libraries:
from fastapi.concurrency import run_in_threadpool
from my_sync_library import SyncAPIClient
@app.get("/")
async def call_sync_library():
my_data = await service.get_my_data()
client = SyncAPIClient()
await run_in_threadpool(client.make_request, data=my_data)Database Conventions
Naming Standards
- Use
lower_case_snakefor all names - Singular form:
post,user,payment_account - Group with prefixes:
payment_account,payment_bill - Date suffixes:
_atfor datetime,_datefor date
Set Naming Conventions in SQLAlchemy
from sqlalchemy import MetaData
POSTGRES_INDEXES_NAMING_CONVENTION = {
"ix": "%(column_0_label)s_idx",
"uq": "%(table_name)s_%(column_0_name)s_key",
"ck": "%(table_name)s_%(constraint_name)s_check",
"fk": "%(table_name)s_%(column_0_name)s_fkey",
"pk": "%(table_name)s_pkey",
}
metadata = MetaData(naming_convention=POSTGRES_INDEXES_NAMING_CONVENTION)Alembic Migration Tips
- Make migrations static and reversible
- Use descriptive slugs:
2024-08-24_add_post_content_idx.py
# alembic.ini
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)sSQL-First, Pydantic-Second
Let the database handle data processing—it's faster:
from sqlalchemy import func, select, text
async def get_posts(creator_id: UUID4) -> list[dict[str, Any]]:
select_query = (
select(
posts.c.id,
posts.c.title,
func.json_build_object(
text("'id', profiles.id"),
text("'username', profiles.username"),
).label("creator"),
)
.select_from(posts.join(profiles, posts.c.owner_id == profiles.c.id))
.where(posts.c.owner_id == creator_id)
)
return await database.fetch_all(select_query)Testing Best Practices
Async Test Client from Day 0
Avoid event loop issues by starting with async tests:
import pytest
from httpx import AsyncClient, ASGITransport
from typing import AsyncGenerator
from src.main import app
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as client:
yield client
@pytest.mark.asyncio
async def test_create_post(client: AsyncClient):
resp = await client.post("/posts")
assert resp.status_code == 201Documentation Tips
Hide Docs in Production
from starlette.config import Config
config = Config(".env")
ENVIRONMENT = config("ENVIRONMENT")
SHOW_DOCS_ENVIRONMENT = ("local", "staging")
app_configs = {"title": "My API"}
if ENVIRONMENT not in SHOW_DOCS_ENVIRONMENT:
app_configs["openapi_url"] = None
app = FastAPI(**app_configs)Enhance Route Documentation
@router.post(
"/endpoints",
response_model=DefaultResponseModel,
status_code=status.HTTP_201_CREATED,
description="Creates a new resource",
tags=["Resources"],
summary="Create Resource",
responses={
status.HTTP_200_OK: {"model": OkResponse},
status.HTTP_201_CREATED: {"model": CreatedResponse},
},
)
async def documented_route():
passUse Ruff for Linting
Ruff is a blazingly-fast linter that replaces black, autoflake, and isort:
#!/bin/sh -e
set -x
ruff check --fix src
ruff format srcConclusion
These patterns represent hard-won insights from production FastAPI deployments. Apply them consistently and your codebase will remain maintainable, performant, and developer-friendly.
Ready to build? ForgeAPI incorporates these best practices out of the box, giving you a production-ready foundation from day one.
Further Reading:
- FastAPI Best Practices (GitHub) - Original source
- FastAPI Tips by Kludex - Additional performance tips