Zhanymkanov - Fastapi-Best-Practices - FastAPI Best Practices and Conventions We Used at Our Startup
Zhanymkanov - Fastapi-Best-Practices - FastAPI Best Practices and Conventions We Used at Our Startup
zhanymkanov /
fastapi-best-practices
Code Issues 3 Pull requests Actions Projects Security Insights
Async Routes
FastAPI is an async framework, in the first place. It is designed to work with async I/O operations and that is the reason it is so fast.
However, FastAPI doesn't restrict you to use only async routes, and the developer can use sync routes as well. This might confuse
beginner developers into believing that they are the same, but they are not.
I/O Intensive Tasks
Under the hood, FastAPI can effectively handle both async and sync I/O operations.
FastAPI runs sync routes in the threadpool and blocking I/O operations won't stop the event loop from executing the tasks.
If the route is defined async then it's called regularly via await and FastAPI trusts you to do only non-blocking I/O operations.
The caveat is that if you violate that trust and execute blocking operations within async routes, the event loop will not be able to run
subsequent tasks until the blocking operation completes.
import asyncio
import time
router = APIRouter()
@router.get("/terrible-ping")
async def terrible_ping():
time.sleep(10) # I/O blocking operation for 10 seconds, the whole process will be blocked
@router.get("/good-ping")
def good_ping():
time.sleep(10) # I/O blocking operation for 10 seconds, but in a separate thread for the whole `good_ping` route
@router.get("/perfect-ping")
async def perfect_ping():
await asyncio.sleep(10) # non-blocking I/O operation
https://github.com/zhanymkanov/fastapi-best-practices 4/14
4/18/25, 12:47 PM zhanymkanov/fastapi-best-practices: FastAPI Best Practices and Conventions we used at our startup
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) # must be greater or equal to 18
favorite_band: MusicBand | None = None # only "AEROSMITH", "QUEEN", "AC/DC" values are allowed to be inputted
website: AnyUrl | None = None
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,
)
return jsonable_encoder(default_dict)
In the example above, we have decided to create a global base model that:
Serializes all datetime fields to a standard format with an explicit timezone
Provides a method to return a dict with only serializable fields
Decouple Pydantic BaseSettings
BaseSettings was a great innovation for reading environment variables, but having a single BaseSettings for the whole app can become
messy over time. To improve maintainability and organization, we have split the BaseSettings across different modules and domains.
# src.auth.config
from datetime import timedelta
class AuthConfig(BaseSettings):
JWT_ALG: str
JWT_SECRET: str
JWT_EXP: int = 5 # minutes
REFRESH_TOKEN_KEY: str
REFRESH_TOKEN_EXP: timedelta = timedelta(days=30)
https://github.com/zhanymkanov/fastapi-best-practices 5/14
4/18/25, 12:47 PM zhanymkanov/fastapi-best-practices: FastAPI Best Practices and Conventions we used at our startup
auth_settings = AuthConfig()
# src.config
from pydantic import PostgresDsn, RedisDsn, model_validator
from pydantic_settings import BaseSettings
class Config(BaseSettings):
DATABASE_URL: PostgresDsn
REDIS_URL: RedisDsn
CORS_ORIGINS: list[str]
CORS_ORIGINS_REGEX: str | None = None
CORS_HEADERS: list[str]
settings = Config()
Dependencies
Beyond Dependency Injection
Pydantic is a great schema validator, but for complex validations that involve calling a database or external services, it is not sufficient.
FastAPI documentation mostly presents dependencies as DI for endpoints, but they are also excellent for request validation.
Dependencies can be used to validate data against database constraints (e.g., checking if an email already exists, ensuring a user is found,
etc.).
# 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
@router.get("/posts/{post_id}/reviews", response_model=list[ReviewsResponse])
async def get_post_reviews(post: dict[str, Any] = Depends(valid_post_id)):
https://github.com/zhanymkanov/fastapi-best-practices 6/14
4/18/25, 12:47 PM zhanymkanov/fastapi-best-practices: FastAPI Best Practices and Conventions we used at our startup
post_reviews = await reviews_service.get_by_post_id(post["id"])
return post_reviews
If we didn't put data validation to dependency, we would have to validate post_id exists for every endpoint and write the same tests for
each of them.
Chain Dependencies
Dependencies can use other dependencies and avoid code repetition for similar logic.
# dependencies.py
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
return post
return post
# router.py
@router.get("/users/{user_id}/posts/{post_id}", response_model=PostResponse)
async def get_user_post(post: dict[str, Any] = Depends(valid_owned_post)):
return post
https://github.com/zhanymkanov/fastapi-best-practices 7/14
4/18/25, 12:47 PM zhanymkanov/fastapi-best-practices: FastAPI Best Practices and Conventions we used at our startup
post = await service.get_by_id(post_id)
if not post:
raise PostNotFound()
return post
return post
if not user["is_creator"]:
raise UserNotCreator()
return user
# router.py
@router.get("/users/{user_id}/posts/{post_id}", response_model=PostResponse)
async def get_user_post(
worker: BackgroundTasks,
post: Mapping = Depends(valid_owned_post),
user: Mapping = Depends(valid_active_creator),
):
"""Get post that belong the active user."""
worker.add_task(notifications_service.send_email, user["id"])
return post
return profile
# src.creators.dependencies
async def valid_creator_id(profile: Mapping = Depends(valid_profile_id)) -> Mapping:
if not profile["is_creator"]:
raise ProfileNotCreator()
return profile
# src.profiles.router.py
@router.get("/profiles/{profile_id}", response_model=ProfileResponse)
async def get_user_profile_by_id(profile: Mapping = Depends(valid_profile_id)):
"""Get profile by id."""
return profile
# src.creators.router.py
@router.get("/creators/{profile_id}", response_model=ProfileResponse)
async def get_user_profile_by_id(
creator_profile: Mapping = Depends(valid_creator_id)
):
"""Get creator's profile by id."""
return creator_profile
app = FastAPI()
class ProfileResponse(BaseModel):
@model_validator(mode="after")
def debug_usage(self):
print("created pydantic model")
return self
@app.get("/", response_model=ProfileResponse)
async def root():
return ProfileResponse()
https://github.com/zhanymkanov/fastapi-best-practices 9/14
4/18/25, 12:47 PM zhanymkanov/fastapi-best-practices: FastAPI Best Practices and Conventions we used at our startup
Logs Output:
[INFO] [2022-08-28 12:00:00.000000] created pydantic model
[INFO] [2022-08-28 12:00:00.000020] created pydantic model
app = FastAPI()
@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)
class ProfileCreate(BaseModel):
username: 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 "
README "special symbol"
)
return password
# src.profiles.routes
from fastapi import APIRouter
router = APIRouter()
@router.post("/profiles")
async def get_creator_posts(profile_data: ProfileCreate):
pass
Response Example:
https://github.com/zhanymkanov/fastapi-best-practices 10/14
4/18/25, 12:47 PM zhanymkanov/fastapi-best-practices: FastAPI Best Practices and Conventions we used at our startup
Docs
1. Unless your API is public, hide docs by default. Show it explicitly on the selected envs only.
from fastapi import FastAPI
from starlette.config import Config
app = FastAPI(**app_configs)
router = APIRouter()
@router.post(
"/endpoints",
response_model=DefaultResponseModel, # default response pydantic model
status_code=status.HTTP_201_CREATED, # default status code
description="Description of the well documented endpoint",
tags=["Endpoint Category"],
summary="Summary of the Endpoint",
responses={
status.HTTP_200_OK: {
"model": OkResponse, # custom pydantic model for 200 response
"description": "Ok Response",
},
status.HTTP_201_CREATED: {
"model": CreatedResponse, # custom pydantic model for 201 response
"description": "Creates something from user request ",
},
status.HTTP_202_ACCEPTED: {
"model": AcceptedResponse, # custom pydantic model for 202 response
"description": "Accepts request and handles it later",
},
},
)
async def documented_route():
pass
https://github.com/zhanymkanov/fastapi-best-practices 11/14
4/18/25, 12:47 PM zhanymkanov/fastapi-best-practices: FastAPI Best Practices and Conventions we used at our startup
Will generate docs like this:
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)
Migrations. Alembic
1. Migrations must be static and revertable. If your migrations depend on dynamically generated data, then make sure the only thing that
is dynamic is the data itself, not its structure.
2. Generate migrations with descriptive names & slugs. Slug is required and should explain the changes.
3. Set human-readable file template for new migrations. We use *date*_*slug*.py pattern, e.g. 2022-08-24_post_content_idx.py
# alembic.ini
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s
# src.posts.schemas
from typing import Any
class Creator(BaseModel):
id: UUID4
first_name: str
last_name: str
username: str
https://github.com/zhanymkanov/fastapi-best-practices 13/14
4/18/25, 12:47 PM zhanymkanov/fastapi-best-practices: FastAPI Best Practices and Conventions we used at our startup
class Post(BaseModel):
id: UUID4
slug: str
title: str
creator: Creator
# src.posts.router
from fastapi import APIRouter, Depends
router = APIRouter()
@router.get("/creators/{creator_id}/posts", response_model=list[Post])
async def get_creator_posts(creator: dict[str, Any] = Depends(valid_creator_id)):
posts = await service.get_posts(creator["id"])
return posts
@pytest.fixture
async def client() -> AsyncGenerator[TestClient, None]:
host, port = "127.0.0.1", "9000"
@pytest.mark.asyncio
async def test_create_post(client: TestClient):
resp = await client.post("/posts")
Unless you have sync db connections (excuse me?) or aren't planning to write integration tests.
Use ruff
With linters, you can forget about formatting the code and focus on writing the business logic.
Ruff is "blazingly-fast" new linter that replaces black, autoflake, isort, and supports more than 600 lint rules.
It's a popular good practice to use pre-commit hooks but just using the script was ok for us
Contributors 15
https://github.com/zhanymkanov/fastapi-best-practices 14/14