---
title: "FastAPI Best Practices"
slug: "fastapi"
category: "backend"
tags: ["backend", "fastapi", "python", "async"]
status: "stable"
last_updated: 2026-05-14
summary: "Pydantic v2 models, dependency injection, end-to-end async, OpenAPI hygiene, and lifespan setup."
related: ["[[coding/python]]", "[[backend/postgres]]", "[[backend/prisma]]"]
---

## Overview

Use FastAPI for Python HTTP services that need OpenAPI, async I/O, and typed request and response models. It pairs well with [[backend/postgres]] via SQLAlchemy or asyncpg, and ships with Pydantic v2. This page covers model design, [[glossary/dependency-injection|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.

```python
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.

```python
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 [[coding/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.

```python
@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`. See [[cheatsheets/http-status-codes]] for the right code per response shape.

## 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.

```python
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.

```python
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. See [[file-organization/project-structure]] for the broader Python layout rules.

## Related

- [[coding/python]]
- [[backend/postgres]]
- [[backend/prisma]]
