Skip to content
Loading

FastAPI Best Practices: What Actually Works in Production

FastAPI Best Practices: What Actually Works in Production hero image

I have shipped FastAPI services that handle millions of requests. I have also inherited FastAPI codebases that looked fine in tutorials and fell apart the moment real traffic hit them. This post is about the gap between those two experiences.

Most FastAPI tutorials get you to a running server in five minutes. That is genuinely great. But they also teach you patterns that will quietly cause problems at scale, and they skip the things that actually matter in a production environment. Let me fix that.


What FastAPI Actually Is

FastAPI is a modern Python web framework built on top of Starlette and Pydantic. It generates OpenAPI docs automatically, enforces request and response validation through type hints, and runs on an ASGI server like Uvicorn or Hypercorn.

It is not magic. It is a well-designed abstraction over tools that have existed for a while. Understanding what sits underneath it helps you avoid treating it like a black box.

ASGI vs WSGI: The One-Sentence Version

WSGI (what Flask and Django traditionally use) handles one request at a time per worker. ASGI (what FastAPI uses) can handle thousands of concurrent connections in a single process by suspending and resuming coroutines while waiting on I/O.

In practice this means: if your application does a lot of waiting (database queries, HTTP calls to external services, file reads), ASGI lets you serve many of those waits concurrently without spawning more processes. But if your code blocks the event loop with synchronous operations, you lose all of that benefit. More on this later.


What Industry Actually Uses FastAPI For

Before getting into code, it helps to know where FastAPI genuinely shines in the real world.

  • ML model serving. FastAPI is the default choice for wrapping PyTorch or TensorFlow models behind an HTTP API. The async architecture handles burst traffic well, and Pydantic gives you request validation without extra boilerplate.
  • Microservices. Internal services that communicate over HTTP benefit from the automatic OpenAPI docs, which other teams can use to integrate without asking you for documentation.
  • Internal tooling. Data pipelines, admin APIs, batch job triggers. FastAPI is fast to build and easy to maintain, which matters when the team writing it also has other things to do.
  • High-throughput APIs. When you have genuinely async I/O (async database drivers, async HTTP clients), FastAPI with Uvicorn can punch well above its weight compared to synchronous frameworks.

A Realistic Project Structure

Flat files work for demos. Real projects need structure.

app/
  main.py           # FastAPI app instance, lifespan, middleware
  dependencies.py   # Shared dependencies (DB session, auth, config)
  routers/
    users.py
    items.py
  schemas/          # Pydantic models
    user.py
    item.py
  services/         # Business logic, separated from route handlers
    user_service.py
  models/           # SQLAlchemy ORM models
    user.py
  core/
    config.py       # Settings with pydantic-settings
    database.py     # Async engine, SessionLocal

The key principle here is that your route handlers should not contain business logic. They should receive a request, call a service function, and return a response. That is it. The moment you start writing database queries directly in route handlers, you have made the code harder to test and harder to reuse.


Lifespan Events (Do It Right)

The @app.on_event("startup") decorator is deprecated. I still see it in tutorials published this year. Use the lifespan context manager instead.

from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.core.database import engine, Base

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup: runs before the app begins accepting requests
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    print("Database tables verified.")
    yield
    # Shutdown: runs after the last request is handled
    await engine.dispose()
    print("Database connections closed.")

app = FastAPI(lifespan=lifespan)

The yield separates startup from shutdown. Everything before it runs on startup, everything after runs on shutdown. It is clean, composable, and it is the pattern that will be supported going forward.


APIRouter: Stop Putting Everything in main.py

Every FastAPI tutorial starts with routes directly on the app object. That is fine for five routes. It is a mess for fifty.

# app/routers/users.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db
from app.schemas.user import UserCreate, UserResponse
from app import services

router = APIRouter(prefix="/api/v1/users", tags=["users"])

@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(payload: UserCreate, db: AsyncSession = Depends(get_db)):
    user = await services.user_service.create_user(db, payload)
    return user

@router.get("/{user_id}", response_model=UserResponse)
async def get_user(user_id: int, db: AsyncSession = Depends(get_db)):
    user = await services.user_service.get_user(db, user_id)
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return user
# app/main.py
from app.routers import users, items

app.include_router(users.router)
app.include_router(items.router)

Notice response_model and status_code on every route. These are not optional decoration. They control what actually gets serialized back to the client, and they protect you from accidentally leaking internal fields (like password hashes) that exist on your ORM model but should never be in the response.


Dependency Injection for Database Sessions

Global database connections are a bad idea. They do not handle reconnection cleanly, they make testing painful, and they leak state between requests. Use dependency injection instead.

# app/core/database.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession

DATABASE_URL = "postgresql+asyncpg://user:password@localhost/dbname"

engine = create_async_engine(DATABASE_URL, pool_size=10, max_overflow=20)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
# app/dependencies.py
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import SessionLocal

async def get_db() -> AsyncSession:
    async with SessionLocal() as session:
        yield session

The session opens at the start of the request and closes when the request finishes, whether it succeeds or raises an exception. The async with handles that cleanup for you. Inject it into any route that needs database access with Depends(get_db).


Pydantic v2 Models Done Right

Pydantic v2 is a rewrite. The configuration API changed, validators changed, and if you are copying patterns from pre-2023 tutorials you are probably using the old API. Here is what current Pydantic v2 looks like.

from pydantic import BaseModel, field_validator, EmailStr
from pydantic import ConfigDict

class UserCreate(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True)

    email: EmailStr
    age: int
    name: str

    @field_validator("age")
    @classmethod
    def age_must_be_positive(cls, v: int) -> int:
        if v < 0:
            raise ValueError("age must be positive")
        return v

class UserResponse(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    email: str
    name: str
    # No password field. This is intentional.

from_attributes=True (previously orm_mode = True) tells Pydantic to read from ORM model attributes instead of a dict. str_strip_whitespace silently trims leading and trailing whitespace from all string fields, which saves you from a category of subtle bugs in user input.


Async vs Sync Route Handlers

This is where I see the most subtle damage in production codebases.

Use async def when your handler awaits something: a database query, an HTTP call, a file read with an async library. If you do not have any awaited operations, use regular def. FastAPI runs synchronous route handlers in a thread pool executor, which keeps them from blocking the event loop.

# Good: async because it awaits a DB call
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(user_id: int, db: AsyncSession = Depends(get_db)):
    return await services.user_service.get_user(db, user_id)

# Good: sync because it does CPU work, no I/O
@router.get("/health")
def health_check():
    return {"status": "ok"}

# Bad: sync blocking call inside an async handler
@router.get("/bad-example")
async def bad_handler():
    import time
    time.sleep(2)  # This blocks the entire event loop for 2 seconds
    return {"done": True}

That time.sleep in an async handler will freeze every other concurrent request for those two seconds. It is not obvious, it does not throw an error, and it will absolutely happen in production if you are not careful about it.


Background Tasks

Some work does not need to complete before you send the response. Sending a welcome email, writing an audit log entry, triggering a downstream notification. For that, FastAPI has BackgroundTasks.

from fastapi import BackgroundTasks

async def send_welcome_email(email: str, name: str):
    # Your email sending logic here
    pass

@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
    payload: UserCreate,
    background_tasks: BackgroundTasks,
    db: AsyncSession = Depends(get_db),
):
    user = await services.user_service.create_user(db, payload)
    background_tasks.add_task(send_welcome_email, user.email, user.name)
    return user

The response goes back to the client immediately. The email sends after. For anything heavier (retries, distributed queues, guaranteed delivery), use a proper task queue like Celery or ARQ. Background tasks are fire-and-forget by design.


Proper Error Handling

HTTPException covers most cases. But sometimes you want to handle specific exception types globally, especially if they bubble up from service code that should not know about HTTP.

# app/main.py
from fastapi import Request
from fastapi.responses import JSONResponse

@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
    return JSONResponse(
        status_code=400,
        content={"detail": str(exc)},
    )

class ResourceNotFoundError(Exception):
    def __init__(self, resource: str, resource_id: int):
        self.resource = resource
        self.resource_id = resource_id

@app.exception_handler(ResourceNotFoundError)
async def not_found_handler(request: Request, exc: ResourceNotFoundError):
    return JSONResponse(
        status_code=404,
        content={"detail": f"{exc.resource} with id {exc.resource_id} not found"},
    )

Your service layer raises ResourceNotFoundError. Your exception handler converts it to a 404. The route handler does not need to know about either. Clean separation, consistent error format across all endpoints.


What Most Tutorials Skip

These are the patterns that hurt you later. I have made most of these mistakes myself.

Using @app.on_event is deprecated and will be removed. The lifespan context manager replaces it entirely. If you are writing new code, do not use it.

Putting database logic in route handlers makes your services untestable and creates routes that are impossible to read. Extract it into service functions. Your future self will thank you.

Skipping response_model means FastAPI serializes whatever your route returns, including any internal fields on your ORM model. A User ORM model with a hashed_password field will leak that field if you do not define a response schema that excludes it.

Ignoring status_code means every successful response returns 200, including resource creation. A POST that creates a resource should return 201. Clients and API consumers depend on status codes being meaningful.

Not using APIRouter leads to a main.py that is 600 lines long and impossible to navigate. Split routes by domain from the start, not after the fact.

Mixing sync blocking calls into async handlers silently degrades performance. CPU-bound work, time.sleep, synchronous database drivers inside async def handlers: all of these block the event loop.


Best Practices Checklist

Before shipping a FastAPI service, run through this list.

| Practice | Why It Matters | | -------------------------------------------------------------- | ---------------------------------------------------- | | Use APIRouter per domain | Keeps main.py clean, enables per-router middleware | | Always set response_model and status_code | Prevents data leaks, correct HTTP semantics | | Use lifespan for startup/shutdown | Deprecation-safe, composable | | Inject DB sessions via Depends | Testable, no global state | | Use Pydantic v2 with model_config | Modern API, better performance | | Use async def only for async I/O | Sync handlers run in thread pool automatically | | Use BackgroundTasks for fire-and-forget | Non-blocking responses | | Set CORS explicitly, never allow_origins=["*"] in production | Basic security hygiene | | Version your API: /api/v1/... | Backwards-compatible changes become possible | | Handle exceptions at the app level | Consistent error format, decoupled service layer |


What to Avoid

A few specific things worth calling out explicitly.

Global state for DB connections. If you instantiate your database engine at module level and share it across requests without a session factory, you will have connection leaks and confusing errors under load.

allow_origins=["*"] in production. Fine for local development. In a real deployment, enumerate the origins that should be allowed. This is one of those things that seems harmless until it is not.

Skipping API versioning. The first time you need to change a response shape, you will wish you had /api/v1/ from the start. Adding it later means updating every client.

Unstructured logging. FastAPI does not do anything special about logging. Set up structured JSON logs (with python-json-logger or similar) from the beginning so your logs are queryable in production.


FastAPI is genuinely one of the best tools in the Python ecosystem right now. It is fast, it has excellent defaults, and the developer experience is hard to beat. But like any tool, it rewards understanding how it works. The tutorials will get you started. These patterns will keep you running.

If you are starting a new FastAPI project, copy the project structure above, set up lifespan, wire up your routers, and define your Pydantic schemas before you write a single route handler. That order matters more than it sounds.