Overview

Use FastAPI for Python HTTP services that need OpenAPI, async I/O, and typed request and response models. It pairs well with postgres via SQLAlchemy or asyncpg, and ships with Pydantic v2. This page covers model design, dependency injection, async discipline, OpenAPI hygiene, and route layout.

Separate read and write Pydantic models

One model per direction. The model that accepts input is not the same as the one that returns output.

from pydantic import BaseModel, EmailStr
from datetime import datetime
 
class UserCreate(BaseModel):
    email: EmailStr
    password: str
 
class UserRead(BaseModel):
    id: str
    email: EmailStr
    created_at: datetime
 
    model_config = {"from_attributes": True}
  • Request models reject fields the client should not set (id, created_at, role flags).
  • Response models hide fields the client should not see (password_hash, internal status).
  • Use model_config = {"from_attributes": True} to build a response model from an ORM instance.
  • Validate at the edge once. Do not re-validate the same payload three layers deep.

Inject dependencies with Depends

Use Depends for anything a route needs that is not in the request body or path: database sessions, the current user, settings, feature flags.

from fastapi import Depends, FastAPI
from sqlalchemy.ext.asyncio import AsyncSession
 
app = FastAPI()
 
async def get_db() -> AsyncSession: ...
async def get_current_user(db: AsyncSession = Depends(get_db)) -> User: ...
 
@app.get("/me", response_model=UserRead)
async def me(user: User = Depends(get_current_user)) -> User:
    return user
  • Dependencies are cached per request. Depends(get_db) returns the same session everywhere in one request.
  • Use yield in a dependency to run teardown (commit, close) after the route returns.
  • Group dependencies into a single chain rather than passing them route by route.

Keep the entire request path async

One blocking call in an async route stalls the event loop for every other request.

  • Use asyncpg, httpx.AsyncClient, or async SQLAlchemy. Not psycopg2, requests, or sync SQLAlchemy in async routes.
  • If a library has no async API, offload it with await run_in_threadpool(...) or asyncio.to_thread(...).
  • Never call time.sleep() in a route. Use await asyncio.sleep().
  • See python for asyncio rules outside FastAPI.

Set response_model, tags, and summary on every route

The OpenAPI schema is part of your API contract. Treat it like the contract.

@app.post(
    "/users",
    response_model=UserRead,
    status_code=201,
    tags=["users"],
    summary="Create a user",
)
async def create_user(payload: UserCreate, db: AsyncSession = Depends(get_db)) -> User: ...
  • response_model filters the route’s return value through Pydantic. Returning extra fields will not leak them.
  • tags group endpoints in the docs. One tag per logical resource.
  • summary is the short label shown in /docs. description (or the route docstring) is the long form.
  • Set status_code explicitly. The default of 200 is wrong for POST and DELETE.

Use BackgroundTasks only for fire-and-forget work

BackgroundTasks runs after the response is sent. Use it for work the caller need not wait on and that is allowed to fail silently.

from fastapi import BackgroundTasks
 
@app.post("/signup")
async def signup(payload: UserCreate, tasks: BackgroundTasks) -> UserRead:
    user = await create_user(payload)
    tasks.add_task(send_welcome_email, user.email)
    return user

For work that must survive a process restart (payments, webhooks, retries), use a real queue: Celery, RQ, Arq, or a database-backed job table. BackgroundTasks dies with the worker.

Run startup and shutdown in a lifespan context

The old @app.on_event decorators are deprecated. Use the async context manager.

from contextlib import asynccontextmanager
 
@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.db = create_engine(settings.database_url)
    yield
    await app.state.db.dispose()
 
app = FastAPI(lifespan=lifespan)

Open pools, warm caches, and load models before yield. Close them after. Lifespan runs once per worker, not once per request.

Lay routes out under app/routers/

One file per resource. Each router defines router = APIRouter(prefix="/users", tags=["users"]) and is mounted in main.py.

app/
  main.py            # FastAPI() + lifespan + include_router calls
  deps.py            # Depends() factories
  routers/
    users.py
    orders.py
  models/            # Pydantic models
  db/                # SQLAlchemy or asyncpg layer

Keep main.py short: imports, app construction, router mounts. Push the rest into modules.