# FastAPI Best Practices Complete FastAPI patterns from [zhanymkanov/fastapi-best-practices](https://github.com/zhanymkanov/fastapi-best-practices). ## Project Structure - Domain-Driven Domain-driven project structure inspired by Netflix's Dispatch. Store all domain directories inside `src` folder. ``` fastapi-project ├── alembic/ ├── src │ ├── auth │ │ ├── router.py # core endpoints │ │ ├── schemas.py # pydantic models │ │ ├── models.py # db models │ │ ├── dependencies.py │ │ ├── config.py │ │ ├── constants.py │ │ ├── exceptions.py │ │ ├── service.py │ │ └── utils.py │ ├── posts │ │ ├── router.py │ │ ├── schemas.py │ │ ├── models.py │ │ └── ... │ ├── config.py # global configs │ ├── models.py # global models │ ├── exceptions.py │ ├── pagination.py │ ├── database.py │ └── main.py ├── tests/ ├── templates/ └── requirements/ ``` Import from other packages with explicit module names: ```python from src.auth import constants as auth_constants from src.notifications import service as notification_service from src.posts.constants import ErrorCode as PostsErrorCode ``` --- ## Async Routes ### I/O Intensive Tasks FastAPI handles sync routes in threadpool, async routes on event loop. Never use blocking operations in async routes. ```python # BAD - blocks event loop @router.get("/terrible-ping") async def terrible_ping(): time.sleep(10) # I/O blocking operation, whole process blocked return {"pong": True} # GOOD - runs in threadpool @router.get("/good-ping") def good_ping(): time.sleep(10) # Blocking but in separate thread return {"pong": True} # PERFECT - non-blocking async @router.get("/perfect-ping") async def perfect_ping(): await asyncio.sleep(10) # Non-blocking I/O return {"pong": True} ``` ### CPU Intensive Tasks CPU-intensive tasks should not be awaited or run in threadpool due to GIL. Send them to workers in another process. ### Sync SDK in Thread Pool If you must use a library that's not async, use `run_in_threadpool`: ```python from fastapi.concurrency import run_in_threadpool from my_sync_library import SyncAPIClient @app.get("/") async def call_my_sync_library(): my_data = await service.get_my_data() client = SyncAPIClient() await run_in_threadpool(client.make_request, data=my_data) ``` --- ## Pydantic Patterns ### Excessively Use Pydantic Pydantic has rich features for validation: ```python 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 controllable global base model: ```python 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 which contains only serializable fields.""" default_dict = self.model_dump() return jsonable_encoder(default_dict) ``` ### Decouple BaseSettings Split BaseSettings across different modules: ```python # src.auth.config from pydantic_settings import BaseSettings class AuthConfig(BaseSettings): JWT_ALG: str JWT_SECRET: str JWT_EXP: int = 5 # minutes REFRESH_TOKEN_KEY: str SECURE_COOKIES: bool = True auth_settings = AuthConfig() # src.config from pydantic import PostgresDsn, RedisDsn from pydantic_settings import BaseSettings class Config(BaseSettings): DATABASE_URL: PostgresDsn REDIS_URL: RedisDsn SITE_DOMAIN: str = "myapp.com" ENVIRONMENT: Environment = Environment.PRODUCTION settings = Config() ``` ### ValueError Becomes ValidationError ```python from pydantic import BaseModel, field_validator class ProfileCreate(BaseModel): username: str password: str @field_validator("password", mode="after") @classmethod def valid_password(cls, password: str) -> str: if not re.match(STRONG_PASSWORD_PATTERN, password): raise ValueError( "Password must contain at least " "one lower character, " "one upper character, " "digit or " "special symbol" ) return password ``` --- ## Dependencies ### Request Validation Dependencies are excellent for request validation: ```python # 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}", response_model=PostResponse) async def get_post_by_id(post: dict[str, Any] = Depends(valid_post_id)): return post @router.put("/posts/{post_id}", response_model=PostResponse) async def update_post( update_data: PostUpdate, post: dict[str, Any] = Depends(valid_post_id), ): updated_post = await service.update(id=post["id"], data=update_data) return updated_post ``` ### Chain Dependencies Dependencies can use other dependencies: ```python 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 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's result within a request's scope by default: ```python # parse_jwt_data is used 3 times but called only once @router.get("/users/{user_id}/posts/{post_id}", response_model=PostResponse) async def get_user_post( worker: BackgroundTasks, post: Mapping = Depends(valid_owned_post), # uses parse_jwt_data user: Mapping = Depends(valid_active_creator), # uses parse_jwt_data ): # parse_jwt_data is called only once, cached for this request worker.add_task(notifications_service.send_email, user["id"]) return post ``` ### Prefer Async Dependencies Sync dependencies run in the thread pool. Prefer async for non-I/O operations. --- ## API Design ### Follow REST Conventions Use consistent variable names in paths: ```python @router.get("/profiles/{profile_id}", response_model=ProfileResponse) async def get_user_profile_by_id(profile: Mapping = Depends(valid_profile_id)): return profile @router.get("/creators/{profile_id}", response_model=ProfileResponse) async def get_user_profile_by_id( creator_profile: Mapping = Depends(valid_creator_id) ): return creator_profile ``` ### Hide Docs by Default Unless your API is public: ```python from fastapi import FastAPI from starlette.config import Config config = Config(".env") ENVIRONMENT = config("ENVIRONMENT") SHOW_DOCS_ENVIRONMENT = ("local", "staging") app_configs = {"title": "My Cool API"} if ENVIRONMENT not in SHOW_DOCS_ENVIRONMENT: app_configs["openapi_url"] = None app = FastAPI(**app_configs) ``` ### Detailed API Documentation ```python from fastapi import APIRouter, status @router.post( "/endpoints", response_model=DefaultResponseModel, status_code=status.HTTP_201_CREATED, description="Description of the well documented endpoint", tags=["Endpoint Category"], summary="Summary of the Endpoint", responses={ status.HTTP_200_OK: { "model": OkResponse, "description": "Ok Response", }, status.HTTP_201_CREATED: { "model": CreatedResponse, "description": "Creates something from user request", }, }, ) async def documented_route(): pass ``` --- ## Testing ### Async Test Client from Day 0 Set the async test client immediately: ```python import pytest from httpx import AsyncClient, ASGITransport 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 ``` --- ## Tooling ### Use Ruff Ruff is blazingly-fast linter that replaces black, autoflake, isort: ```bash #!/bin/sh -e set -x ruff check --fix src ruff format src ``` --- ## Deslop - Remove Emojis AI-generated code often includes emojis. Remove them: ```python import re def remove_emoji(text: str) -> str: """Remove emoji characters from text.""" emoji_pattern = re.compile( "[" "\U0001F600-\U0001F64F" # emoticons "\U0001F300-\U0001F5FF" # symbols & pictographs "\U0001F680-\U0001F6FF" # transport & map symbols "\U0001F1E0-\U0001F1FF" # flags (iOS) "\U00002702-\U000027B0" "\U000024C2-\U0001F251" "]+", flags=re.UNICODE ) return emoji_pattern.sub("", text) ```