* Refresh examples for modern stack; promote AGENTS.md to canonical agent ruleset Replaces stale code in README.md (python-jose, async_asgi_testclient, json_encoders, contradictory `Field(ge=18, default=None)`, unused `model_validator` import) with current equivalents (PyJWT, httpx + ASGITransport, `@field_serializer`). Adds a top-of-file pointer to AGENTS.md, a tested-versions line, and inline notes for `Annotated[T, Depends(...)]` and SQLAlchemy 2.0 async without rewriting every example. Adds a `dependency_overrides` testing subsection and a BackgroundTasks vs. task-queue decision table. Rewrites AGENTS.md as a terse, machine-readable mirror: compatibility matrix at the top, Do/Don't blocks throughout, an explicit anti-patterns table covering common AI-agent mistakes, and a quick-reference table. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Sync code-only fixes into README_ZH.md Mirrors the safe code-only changes from the parent commit into the Chinese translation: - python-jose -> PyJWT (import + exception class) in both dependency examples - async_asgi_testclient -> httpx.AsyncClient + ASGITransport (and fixes the previously broken example that mixed both imports) - Pydantic json_encoders -> @field_serializer for the CustomModel example - Field(ge=18, default=None) -> Field(ge=18) (the constraint and the default contradict each other) - Removes unused `model_validator` import No prose changes. Existing Chinese descriptions still accurately describe the new code. Sections newly added in README.md (BackgroundTasks vs task queue, dependency_overrides, the agent banner, the version line, and the Annotated/SQLAlchemy 2.0 inline notes) are not ported here because they require translation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Trim docs/changelog voice from refresh Pass over the refresh to keep the article in best-practices voice, not docs or changelog: - Drop the "Note on syntax" blockquote in README's Dependencies section. It hedged ("Both styles work. New code should prefer Annotated.") while every example below kept the default-arg form. Removing the callout returns the section to its original shape; a full Annotated migration can be its own PR. - Remove the "json_encoders is deprecated in Pydantic v2" inline explainer. The code change already says it. - Rewrite the databases blockquote into a single in-voice line ("For new projects, reach for SQLAlchemy 2.0's async API...") instead of an apologetic disclaimer. - Collapse the "Earlier versions of this article recommended async_asgi_testclient" blockquote into a direct sentence. - Drop the "this file wins — README is narrative, this is the spec" precedence line in AGENTS.md. The two files are peers; neither outranks the other. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
448 lines
17 KiB
Markdown
448 lines
17 KiB
Markdown
# FastAPI Best Practices for AI Agents
|
|
|
|
A machine-readable companion to [README.md](./README.md) for AI coding agents
|
|
working in FastAPI projects. Same rules, restructured for fast pattern matching:
|
|
version pins, Do/Don't blocks, anti-patterns, and a quick-reference table.
|
|
|
|
## Compatibility Matrix
|
|
|
|
Pin to these versions or newer. Examples in this file assume them.
|
|
|
|
| Dependency | Minimum | Notes |
|
|
|------------------|-----------|------------------------------------------------------|
|
|
| Python | 3.11 | Required for `StrEnum` and `X \| Y` union syntax |
|
|
| FastAPI | 0.115 | `Annotated[T, Depends(...)]` is the idiomatic form |
|
|
| Pydantic | 2.7 | v1 APIs (`json_encoders`, `.dict()`) are removed |
|
|
| pydantic-settings| 2.4 | Lives in a separate package since Pydantic v2 |
|
|
| SQLAlchemy | 2.0 | Use the async API (`AsyncSession`, `async_sessionmaker`) |
|
|
| Alembic | 1.13 | Async-aware migrations |
|
|
| httpx | 0.27 | Use `ASGITransport` for in-process tests |
|
|
| PyJWT | 2.9 | Use this, not the unmaintained `python-jose` |
|
|
| ruff | 0.6 | Replaces black, isort, autoflake |
|
|
|
|
## Project Structure
|
|
|
|
Organize by domain, not by file type. One package per bounded context.
|
|
|
|
```
|
|
src/
|
|
├── {domain}/ # e.g., auth/, posts/, aws/
|
|
│ ├── router.py # API endpoints
|
|
│ ├── schemas.py # Pydantic models
|
|
│ ├── models.py # SQLAlchemy ORM models
|
|
│ ├── service.py # Business logic
|
|
│ ├── dependencies.py # Route dependencies
|
|
│ ├── config.py # Domain-scoped BaseSettings
|
|
│ ├── constants.py # Constants and error codes
|
|
│ ├── exceptions.py # Domain-specific exceptions
|
|
│ └── utils.py # Helper functions
|
|
├── config.py # Global BaseSettings
|
|
├── models.py # Shared Pydantic / ORM bases
|
|
├── exceptions.py # Global exceptions
|
|
├── database.py # Async engine + session factory
|
|
└── main.py # FastAPI app + lifespan
|
|
```
|
|
|
|
**Cross-domain imports**: always use the explicit module name. Never `from src.auth import *`.
|
|
|
|
```python
|
|
from src.auth import constants as auth_constants
|
|
from src.notifications import service as notification_service
|
|
from src.posts.constants import ErrorCode as PostsErrorCode
|
|
```
|
|
|
|
## Async Routes
|
|
|
|
### Decision rule
|
|
|
|
| Route does this | Use |
|
|
|----------------------------------------|-------------|
|
|
| `await`-able non-blocking I/O | `async def` |
|
|
| Blocking I/O (no async client exists) | `def` (sync, runs in threadpool) |
|
|
| Mix of both | `async def` + `run_in_threadpool` for the blocking part |
|
|
| CPU-bound work (>50 ms compute) | Offload to a worker process (Celery / RQ / Arq) |
|
|
|
|
### Do / Don't
|
|
|
|
```python
|
|
# DON'T — blocking call inside async route freezes the entire event loop
|
|
@router.get("/bad")
|
|
async def bad():
|
|
time.sleep(10) # blocks every request on this worker
|
|
return {"ok": True}
|
|
|
|
# DO — sync route lets FastAPI run it in a threadpool
|
|
@router.get("/sync-ok")
|
|
def sync_ok():
|
|
time.sleep(10) # blocks one threadpool worker, not the loop
|
|
return {"ok": True}
|
|
|
|
# DO — async route with awaitable sleep
|
|
@router.get("/async-ok")
|
|
async def async_ok():
|
|
await asyncio.sleep(10) # yields control, loop keeps serving requests
|
|
return {"ok": True}
|
|
|
|
# DO — async route that has to call a sync library
|
|
from fastapi.concurrency import run_in_threadpool
|
|
|
|
@router.get("/wrap")
|
|
async def wrap():
|
|
result = await run_in_threadpool(legacy_sync_client.fetch, "id")
|
|
return result
|
|
```
|
|
|
|
### Threadpool caveats
|
|
- Default Starlette threadpool size is 40. Saturating it slows every sync route.
|
|
- Threads cost more than coroutines. Don't use sync routes "just because."
|
|
|
|
## Pydantic
|
|
|
|
### Use built-in validators
|
|
```python
|
|
from enum import StrEnum
|
|
from pydantic import AnyUrl, BaseModel, EmailStr, Field
|
|
|
|
|
|
class MusicBand(StrEnum):
|
|
AEROSMITH = "AEROSMITH"
|
|
QUEEN = "QUEEN"
|
|
ACDC = "AC/DC"
|
|
|
|
|
|
class UserCreate(BaseModel):
|
|
first_name: str = Field(min_length=1, max_length=128)
|
|
username: str = Field(min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_-]+$")
|
|
email: EmailStr
|
|
age: int = Field(ge=18) # required, must be >= 18
|
|
favorite_band: MusicBand | None = None
|
|
website: AnyUrl | None = None
|
|
```
|
|
|
|
> **Don't** write `Field(ge=18, default=None)`. The constraint and the default contradict
|
|
> each other. Decide: required (`Field(ge=18)`) or optional (`int | None = Field(default=None, ge=18)`).
|
|
|
|
### Custom base model — modern serialization
|
|
|
|
`json_encoders` is deprecated in Pydantic v2. Use `@field_serializer` for per-field rules,
|
|
or annotate a custom type with `PlainSerializer`.
|
|
|
|
```python
|
|
from datetime import datetime
|
|
from zoneinfo import ZoneInfo
|
|
from pydantic import BaseModel, ConfigDict, field_serializer
|
|
|
|
|
|
class CustomModel(BaseModel):
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
@field_serializer("*", when_used="json", check_fields=False)
|
|
def _serialize_datetimes(self, value):
|
|
if isinstance(value, datetime):
|
|
if value.tzinfo is None:
|
|
value = value.replace(tzinfo=ZoneInfo("UTC"))
|
|
return value.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
return value
|
|
```
|
|
|
|
### Split BaseSettings by domain
|
|
|
|
`pydantic-settings` is its own package since Pydantic v2.
|
|
|
|
```python
|
|
# src/auth/config.py
|
|
from datetime import timedelta
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
|
|
class AuthConfig(BaseSettings):
|
|
model_config = SettingsConfigDict(env_prefix="AUTH_", env_file=".env", extra="ignore")
|
|
|
|
JWT_ALG: str
|
|
JWT_SECRET: str
|
|
JWT_EXP_MINUTES: int = 5
|
|
REFRESH_TOKEN_KEY: str
|
|
REFRESH_TOKEN_EXP: timedelta = timedelta(days=30)
|
|
SECURE_COOKIES: bool = True
|
|
|
|
|
|
auth_settings = AuthConfig()
|
|
```
|
|
|
|
## Dependencies
|
|
|
|
### Use Annotated, not default-arg `Depends(...)`
|
|
|
|
`Annotated[T, Depends(...)]` is the idiomatic form since FastAPI 0.95 and avoids
|
|
gotchas with default values.
|
|
|
|
```python
|
|
# DO — modern Annotated form
|
|
from typing import Annotated
|
|
from fastapi import Depends
|
|
|
|
PostDep = Annotated[dict, Depends(valid_post_id)]
|
|
|
|
@router.get("/posts/{post_id}")
|
|
async def get_post(post: PostDep):
|
|
return post
|
|
|
|
# Avoid — default-argument form (still works, but legacy)
|
|
@router.get("/posts/{post_id}")
|
|
async def get_post(post: dict = Depends(valid_post_id)):
|
|
return post
|
|
```
|
|
|
|
### Validate inside dependencies (not just inject)
|
|
```python
|
|
async def valid_post_id(post_id: UUID4) -> dict:
|
|
post = await service.get_by_id(post_id)
|
|
if not post:
|
|
raise PostNotFound()
|
|
return post
|
|
```
|
|
|
|
### Chain dependencies for reuse
|
|
```python
|
|
async def valid_owned_post(
|
|
post: Annotated[dict, Depends(valid_post_id)],
|
|
token_data: Annotated[dict, Depends(parse_jwt_data)],
|
|
) -> dict:
|
|
if post["creator_id"] != token_data["user_id"]:
|
|
raise UserNotOwner()
|
|
return post
|
|
```
|
|
|
|
### Rules
|
|
- Dependencies are **cached per request**. Same `Depends(x)` called 5 times in one request → `x` runs once.
|
|
- Prefer `async def` dependencies. Sync deps run in the threadpool — wasted overhead for small CPU-only checks.
|
|
- Use **the same path-variable name** across endpoints when you want to share a dependency (e.g. `profile_id` in both `/profiles/{profile_id}` and `/creators/{profile_id}`).
|
|
|
|
## Authentication — JWT
|
|
|
|
Use **`PyJWT`**, not `python-jose` (unmaintained).
|
|
|
|
```python
|
|
import jwt # PyJWT
|
|
from jwt.exceptions import InvalidTokenError
|
|
|
|
def decode_token(token: str) -> dict:
|
|
try:
|
|
return jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALG])
|
|
except InvalidTokenError as exc:
|
|
raise InvalidCredentials() from exc
|
|
```
|
|
|
|
## Database — SQLAlchemy 2.0 async
|
|
|
|
Prefer SQLAlchemy 2.0's async API. `encode/databases` is in maintenance mode — don't pick it for new projects.
|
|
|
|
```python
|
|
# src/database.py
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
|
|
engine = create_async_engine(str(settings.DATABASE_URL), pool_pre_ping=True)
|
|
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
|
|
async def get_db() -> AsyncSession:
|
|
async with SessionFactory() as session:
|
|
yield session
|
|
```
|
|
|
|
### Naming conventions
|
|
- `lower_case_snake`
|
|
- Singular tables: `post`, `user`, `post_like`
|
|
- Group with prefix: `payment_account`, `payment_bill`
|
|
- `_at` suffix for `datetime`, `_date` suffix for `date`
|
|
- Use the same FK column name everywhere it appears (`profile_id`, not `user_id` in some tables and `profile_id` in others)
|
|
|
|
### Index naming convention
|
|
```python
|
|
from sqlalchemy import MetaData
|
|
|
|
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)
|
|
```
|
|
|
|
### SQL-first, Pydantic-second
|
|
- Do joins, aggregation, and JSON shaping in SQL — Postgres is faster than CPython at this.
|
|
- Hydrate the result into Pydantic only for response validation, not for transformation.
|
|
|
|
## Background work — BackgroundTasks vs Celery
|
|
|
|
| Use BackgroundTasks when… | Use Celery / Arq / RQ when… |
|
|
|------------------------------------------|--------------------------------------------|
|
|
| Task is < 1 second | Task takes seconds to minutes |
|
|
| Failure can be silently dropped | You need retries, dead-letter, or visibility|
|
|
| Task is in-process (send email, log row) | Task is CPU-heavy or needs a separate pool |
|
|
| You don't need scheduling | You need cron, ETA, or rate limiting |
|
|
|
|
```python
|
|
from fastapi import BackgroundTasks
|
|
|
|
@router.post("/signup")
|
|
async def signup(data: SignupIn, bg: BackgroundTasks):
|
|
user = await service.create_user(data)
|
|
bg.add_task(send_welcome_email, user.email) # fire-and-forget, in-process
|
|
return user
|
|
```
|
|
|
|
> BackgroundTasks run **after the response is sent, in the same worker process**. If the
|
|
> worker dies, the task is lost. There is no retry. Don't use them for anything you'd
|
|
> page on.
|
|
|
|
## Testing
|
|
|
|
### Async client from day one
|
|
```python
|
|
import pytest
|
|
from httpx import AsyncClient, ASGITransport
|
|
|
|
from src.main import app
|
|
|
|
|
|
@pytest.fixture
|
|
async def client():
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
yield ac
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_post(client: AsyncClient):
|
|
resp = await client.post("/posts", json={"title": "hi"})
|
|
assert resp.status_code == 201
|
|
```
|
|
|
|
> **Don't** use `async_asgi_testclient` — it's unmaintained. The example above (httpx +
|
|
> `ASGITransport`) is the supported path.
|
|
|
|
### Override dependencies in tests
|
|
Don't monkeypatch internals. Use FastAPI's built-in `dependency_overrides`.
|
|
|
|
```python
|
|
from src.auth.dependencies import parse_jwt_data
|
|
from src.main import app
|
|
|
|
|
|
def fake_user():
|
|
return {"user_id": "00000000-0000-0000-0000-000000000001"}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _override_auth():
|
|
app.dependency_overrides[parse_jwt_data] = fake_user
|
|
yield
|
|
app.dependency_overrides.clear()
|
|
```
|
|
|
|
## Migrations (Alembic)
|
|
|
|
- Migrations must be static and reversible.
|
|
- Use the async template: `alembic init -t async migrations`
|
|
- Descriptive filenames:
|
|
```ini
|
|
# alembic.ini
|
|
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s
|
|
```
|
|
→ `2026-04-14_add_post_content_idx.py`
|
|
|
|
## API documentation
|
|
|
|
### Hide docs outside selected envs
|
|
```python
|
|
from fastapi import FastAPI
|
|
from src.config import settings
|
|
|
|
SHOW_DOCS_IN = {"local", "staging"}
|
|
app_kwargs = {"title": "My API"}
|
|
if settings.ENVIRONMENT not in SHOW_DOCS_IN:
|
|
app_kwargs["openapi_url"] = None # disables /docs and /redoc
|
|
|
|
app = FastAPI(**app_kwargs)
|
|
```
|
|
|
|
### Document endpoints fully
|
|
```python
|
|
from fastapi import APIRouter, status
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.post(
|
|
"/items",
|
|
response_model=ItemResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
summary="Create an item",
|
|
description="Creates an item owned by the authenticated user.",
|
|
tags=["items"],
|
|
responses={
|
|
status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse, "description": "Validation error"},
|
|
status.HTTP_409_CONFLICT: {"model": ErrorResponse, "description": "Slug already exists"},
|
|
},
|
|
)
|
|
async def create_item(payload: ItemCreate) -> ItemResponse: ...
|
|
```
|
|
|
|
## Linting
|
|
|
|
```shell
|
|
ruff check --fix src
|
|
ruff format src
|
|
```
|
|
|
|
Add to a pre-commit hook or run in CI. Ruff replaces black + isort + autoflake + most of flake8.
|
|
|
|
---
|
|
|
|
## Anti-patterns — common AI-agent mistakes
|
|
|
|
If you're an agent reviewing a diff, check for these. Each is a real failure mode I've
|
|
seen agents introduce.
|
|
|
|
| Anti-pattern | Why it's wrong | Fix |
|
|
|---|---|---|
|
|
| `requests.get(...)` inside `async def` | Blocks the event loop. `requests` is sync. | Use `httpx.AsyncClient` or `await run_in_threadpool(requests.get, ...)`. |
|
|
| `time.sleep` / `open()` / sync DB driver inside `async def` | Same — blocks the loop. | Use the async equivalent (`asyncio.sleep`, `aiofiles`, async driver). |
|
|
| `from jose import jwt` | `python-jose` is unmaintained. | `import jwt` (PyJWT). |
|
|
| `from async_asgi_testclient import TestClient` | Unmaintained. | `httpx.AsyncClient` + `ASGITransport`. |
|
|
| `model_config = ConfigDict(json_encoders={...})` | Deprecated in Pydantic v2. | `@field_serializer` or `Annotated[T, PlainSerializer(...)]`. |
|
|
| `Field(ge=18, default=None)` | Constraint contradicts the default. | Pick required or optional, not both. |
|
|
| `def get_user(id: int = Depends(...))` (default-arg form) | Legacy; gotchas with default values. | `user: Annotated[User, Depends(...)]`. |
|
|
| Catching `Exception` around a route's body | Hides bugs and turns 500s into silent 200s. | Catch the specific exception class; raise `HTTPException` with a meaningful status. |
|
|
| `BackgroundTasks` for anything you'd page on | No retry, dies with the worker. | Use Celery / Arq / RQ. |
|
|
| Calling a sync ORM session inside `async def` | Blocks the loop, may deadlock the pool. | Use `AsyncSession`. |
|
|
| Returning a Pydantic model and *also* setting `response_model=` to that same class | Model gets constructed twice (validate + serialize). | Either return a `dict`/ORM row and let `response_model` validate, or drop `response_model` and trust the return type. |
|
|
| Importing across domains via deep paths (`from src.auth.service.user import ...`) | Tight coupling, hard to refactor. | `from src.auth import service as auth_service`. |
|
|
| Reusing one `BaseSettings` for the whole app | Hard to reason about, every domain reads every var. | One `BaseSettings` per domain. |
|
|
| Mocking the database in integration tests | Mock/prod divergence eventually fires in prod. | Use a real DB (testcontainers, ephemeral schema) and `dependency_overrides` for auth/external services. |
|
|
|
|
## Quick reference
|
|
|
|
| Scenario | Solution |
|
|
|--------------------------------------|---------------------------------------------------|
|
|
| Non-blocking I/O | `async def` route with `await` |
|
|
| Blocking I/O (no async client) | `def` route (sync, runs in threadpool) |
|
|
| Sync library inside async route | `await run_in_threadpool(fn, *args)` |
|
|
| CPU-intensive work | Celery / Arq / RQ worker process |
|
|
| Request validation against DB | Dependency that loads + validates + returns |
|
|
| Reuse validation across routes | Chain dependencies |
|
|
| Inject dependency in modern style | `Annotated[T, Depends(...)]` |
|
|
| Per-request dep caching | Default behavior — same `Depends(x)` runs once |
|
|
| Per-domain config | One `BaseSettings` subclass per domain |
|
|
| Custom datetime serialization | `@field_serializer` |
|
|
| Fire-and-forget short task | `BackgroundTasks` |
|
|
| Reliable / scheduled / heavy task | Celery / Arq / RQ |
|
|
| JWT decode | `PyJWT` (`import jwt`) |
|
|
| Async DB | SQLAlchemy 2.0 async (`AsyncSession`) |
|
|
| HTTP test client | `httpx.AsyncClient` + `ASGITransport` |
|
|
| Swap dep in tests | `app.dependency_overrides[dep] = fake` |
|
|
| Lint + format | `ruff check --fix` + `ruff format` |
|