FastAPI makes it easy to get started, but production code requires discipline. Over time I've settled on a set of patterns that I apply to every project regardless of size. These aren't just best practices — they're things that have saved me from production incidents.
1. Always use APIRouter with prefix and tags
Never define routes directly on the app instance. Every domain gets its own router file under api/v1/endpoints/, with a prefix and tags for auto-generated docs.
# api/v1/endpoints/users.py
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me", response_model=UserResponse)
async def get_current_user(
user: Annotated[User, Depends(get_active_user)],
) -> UserResponse:
return user2. Thin endpoints, fat services
Endpoint functions should do exactly two things: validate input (Pydantic handles this) and call a service. Business logic never lives in the endpoint. This makes testing trivial — you test the service in isolation with a mock DB.
# ✅ endpoint delegates immediately
@router.post("/", response_model=OrderResponse, status_code=201)
async def create_order(
payload: OrderCreate,
service: Annotated[OrderService, Depends(get_order_service)],
) -> OrderResponse:
return await service.create(payload)
# ❌ logic in endpoint
@router.post("/")
async def create_order(payload: dict, db=Depends(get_db)):
# 40 lines of business logic here — don't do this
...3. Pydantic v2 schemas at every boundary
Separate schemas for Create, Update, and Response. Never expose internal DB fields in response models. The response_model parameter on the route decorator is enforced — FastAPI will strip any fields not declared in the model.
class OrderCreate(BaseModel):
product_id: str
quantity: int = Field(..., gt=0)
class OrderResponse(BaseModel):
id: str
product_id: str
quantity: int
status: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)4. Async Motor for MongoDB
If you're using MongoDB, use Motor (the async driver) end-to-end. Never mix sync pymongo calls into async routes — it blocks the event loop. Beanie ODM builds on top of Motor and gives you Pydantic-native document models.
# Document model with Beanie
class Order(Document):
product_id: str
quantity: int
status: OrderStatus = OrderStatus.PENDING
class Settings:
name = "orders"
indexes = [
IndexModel([("product_id", ASCENDING)]),
]5. Background tasks with TaskIQ
For async jobs (sending emails, processing uploads, webhook retries), I use TaskIQ over Celery for new projects. It's fully async-native, plays well with FastAPI's lifespan, and supports multiple brokers (Redis, RabbitMQ, NATS).
broker = RedisBroker(settings.redis_url)
tkq = TaskiqScheduler(broker=broker)
@broker.task(task_name="tasks.send_welcome_email")
async def send_welcome_email(user_id: str) -> None:
user = await User.get(PydanticObjectId(user_id))
await mailer.send(user.email, template="welcome")6. Global exception handlers
Register exception handlers on the app instance, not try/except in every endpoint. Define a hierarchy of custom exceptions — ResourceNotFound, PermissionDenied, ValidationError — each with a default status code and message.
class AppException(Exception):
status_code: int = 500
detail: str = "Internal server error"
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)7. Docker + multi-stage builds
A multi-stage Dockerfile keeps the final image lean. Build deps stay in the builder stage; only the virtualenv gets copied into the runner. The result is a 150MB image instead of 900MB.
FROM python:3.12-slim AS builder
WORKDIR /app
RUN pip install uv
COPY requirements.txt .
RUN uv venv && uv pip install -r requirements.txt
FROM python:3.12-slim AS runner
WORKDIR /app
COPY --from=builder /app/.venv ./.venv
COPY . .
ENV PATH="/app/.venv/bin:$PATH"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]Putting it all together
These seven patterns form a baseline that scales from a weekend side project to a team of five engineers. The investment pays off the first time you need to debug a production issue at 2am — clean separation of concerns means you know exactly where to look.