Back to Blog
Article

FastAPI Best Practices: Production-Ready Patterns for Startups

By ForgeAPI Team

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.txt

Key Principles

  1. Domain-first organization: Each feature module contains all related code
  2. 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 PostsErrorCode

Understanding 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 sync routes 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 = None

Custom 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 post

Dependency 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 post

Prefer 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 creator

Response 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_model

For 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

  1. Use lower_case_snake for all names
  2. Singular form: post, user, payment_account
  3. Group with prefixes: payment_account, payment_bill
  4. Date suffixes: _at for datetime, _date for 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

  1. Make migrations static and reversible
  2. Use descriptive slugs: 2024-08-24_add_post_content_idx.py
# alembic.ini
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s

SQL-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 == 201

Documentation 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():
    pass

Use 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 src

Conclusion

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: