FastAPI is one of the most opinionated Python frameworks — in a good way. It has clear patterns for dependency injection, async route handlers, Pydantic models, and type annotations. Those patterns are also exactly the kind of thing an AI coding agent will ignore or get subtly wrong without explicit instruction.
Out of the box, Claude Code or Cursor working on a FastAPI project will:
- Mix
async defanddefroute handlers without understanding the performance implications - Place Pydantic models and SQLAlchemy models in the same file because nothing told it not to
- Use
TestClientwhenAsyncClientis needed for async endpoints - Generate authentication middleware that doesn’t match your existing
OAuth2PasswordBearersetup - Create new utility functions instead of wiring through
Depends()
An AGENTS.md file in your FastAPI project root fixes all of this. It’s not documentation for humans — it’s a machine-readable contract that shapes how the agent understands your codebase before it writes a single line.
Why FastAPI Specifically Needs AGENTS.md
Most Python web frameworks have loose conventions. FastAPI doesn’t. The framework rewards you for following its patterns precisely: async-first route handlers, Pydantic for validation at every boundary, dependency injection for shared logic, and OpenAPI schema as a first-class output.
When an agent doesn’t know these patterns, it falls back to generic Python conventions. Those aren’t wrong — but they’re not FastAPI. You end up reviewing a lot of code that technically works but doesn’t fit the project.
Three things make FastAPI particularly worth instrumenting:
Async is all-or-nothing in practice. A sync database call inside an async def route blocks the entire event loop. An agent that doesn’t know your DB layer is async (or isn’t) will write blocking code that passes tests and fails in production under load.
Dependency injection is the API. FastAPI’s Depends() system is how the framework handles database sessions, authentication, rate limiting, and shared configuration. Agents that don’t know about your dependency hierarchy will either bypass it with direct instantiation or create parallel implementations that break your middleware.
Pydantic models serve two distinct roles. Your request/response schemas (validation at the API boundary) and your ORM models (database representations) look similar but should never be conflated. Without explicit instruction, agents collapse them — especially with SQLModel, which deliberately blurs the line.
AGENTS.md vs CLAUDE.md for FastAPI Projects
These two files are not interchangeable, and mixing up their purpose is a common mistake.
AGENTS.md is the agentic-context file. It tells AI agents how to operate in your codebase: where files live, what commands to run, what patterns to follow when generating code, and what to avoid. It’s read by Claude Code, OpenAI Codex, Cursor, and any agent that supports the spec. Keep it technical and prescriptive.
CLAUDE.md (Claude Code specific) adds Claude-specific behaviors on top: slash command definitions, memory hints, project-specific personas, and instructions for how Claude should explain things to you. It’s the right place for “when in doubt, ask before touching authentication code” or “this project follows a specific migration naming convention — here’s the pattern.”
For a FastAPI project with a single developer or small team, a single AGENTS.md often covers everything. Add CLAUDE.md when you have Claude-specific workflows that don’t apply to other agents, or when you want to encode your review preferences and communication style.
A reasonable split:
AGENTS.md → project architecture, patterns, commands, conventions
CLAUDE.md → workflow preferences, asking behavior, review style, shortcuts
Project Structure Patterns to Encode
The project structure section of your AGENTS.md is load-bearing. FastAPI projects vary more than most frameworks — there’s no Rails-style mandated directory layout — so agents need explicit guidance.
Here’s a structure that scales well:
project/
├── app/
│ ├── main.py # FastAPI app instantiation, middleware, startup events
│ ├── dependencies.py # Shared Depends() functions (db session, current user, etc.)
│ ├── config.py # pydantic-settings BaseSettings class
│ ├── api/
│ │ ├── v1/
│ │ │ ├── router.py # APIRouter, include_router() calls
│ │ │ └── endpoints/
│ │ │ ├── users.py
│ │ │ └── items.py
│ │ └── v2/ # future version, same structure
│ ├── models/
│ │ └── user.py # SQLAlchemy ORM models only
│ ├── schemas/
│ │ └── user.py # Pydantic request/response schemas only
│ ├── crud/
│ │ └── user.py # Database operations, takes db session as arg
│ └── core/
│ ├── security.py # JWT creation, password hashing
│ └── database.py # Engine creation, SessionLocal, Base
├── tests/
│ ├── conftest.py # pytest fixtures, test db setup
│ └── api/
│ └── v1/
│ └── test_users.py
├── alembic/ # Database migrations
├── AGENTS.md
└── pyproject.toml
In AGENTS.md, you document this explicitly so the agent never has to guess:
## Project Structure
The project follows a layered architecture. When adding a new resource:
1. `app/models/<resource>.py` — SQLAlchemy ORM model
2. `app/schemas/<resource>.py` — Pydantic request/response schemas
3. `app/crud/<resource>.py` — Database operations (no route logic here)
4. `app/api/v1/endpoints/<resource>.py` — Route handlers (no DB logic here)
5. Register the router in `app/api/v1/router.py`
Do NOT put Pydantic schemas in the models directory or ORM models in schemas.
Do NOT put database queries directly in route handlers — always go through crud/.
Router Organization and Versioning
FastAPI’s APIRouter with versioning is clean but easy to get wrong when an agent adds new endpoints. Your AGENTS.md should make the router inclusion pattern explicit:
## API Versioning
Routes are versioned. New endpoints go in `app/api/v1/endpoints/`.
Router registration in `app/api/v1/router.py`:
```python
from fastapi import APIRouter
from app.api.v1.endpoints import users, items, auth
api_router = APIRouter()
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(items.router, prefix="/items", tags=["items"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
In app/main.py:
app.include_router(api_router, prefix="/api/v1")
Result: endpoints are available at /api/v1/users/, /api/v1/items/, etc.
Do not put route handlers directly in main.py.
## Dependency Injection Patterns
This is the section that prevents the most bugs. FastAPI's `Depends()` system is powerful and agents frequently bypass it by creating new instances directly. Your AGENTS.md needs to document the dependency chain explicitly.
```markdown
## Dependency Injection
Core dependencies live in `app/dependencies.py`. Always use `Depends()` — never
instantiate services or create DB sessions directly in route handlers.
### Database Session
```python
# app/dependencies.py
from app.core.database import SessionLocal
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
try:
yield session
finally:
await session.close()
Usage in routes:
@router.get("/users/{user_id}")
async def get_user(
user_id: int,
db: AsyncSession = Depends(get_db)
):
...
Current User
# app/dependencies.py
from app.core.security import verify_token
from app.crud.user import get_user_by_id
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
) -> User:
user_id = verify_token(token)
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user
When adding authentication to a route, import and use get_current_user —
do not re-implement token validation inline.
## Async Patterns
This is where agents make the most consequential mistakes. Document the async conventions explicitly.
```markdown
## Async Rules
**All route handlers must be `async def`.** This project uses an async database
driver (asyncpg / aiosqlite). Sync route handlers block the event loop.
```python
# CORRECT
@router.get("/items/{item_id}")
async def get_item(item_id: int, db: AsyncSession = Depends(get_db)):
return await crud.item.get(db, item_id)
# WRONG — do not do this
@router.get("/items/{item_id}")
def get_item(item_id: int, db: AsyncSession = Depends(get_db)):
...
CRUD functions must be async:
# app/crud/item.py
async def get(db: AsyncSession, item_id: int) -> Item | None:
result = await db.execute(select(Item).where(Item.id == item_id))
return result.scalar_one_or_none()
Background tasks for fire-and-forget work:
Use BackgroundTasks only for operations that should not block the response
(sending email, logging to external services). Do not use background tasks for
operations that the caller needs the result of.
@router.post("/users/")
async def create_user(
user_in: UserCreate,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db)
):
user = await crud.user.create(db, user_in)
background_tasks.add_task(send_welcome_email, user.email)
return user
## Pydantic Schema Conventions
Pydantic models for FastAPI request/response handling follow specific patterns that agents should know:
```markdown
## Pydantic Schema Conventions
Schemas live in `app/schemas/`. They are separate from ORM models in `app/models/`.
Standard schema pattern for a resource:
```python
# app/schemas/user.py
from pydantic import BaseModel, EmailStr
from datetime import datetime
class UserBase(BaseModel):
email: EmailStr
full_name: str | None = None
class UserCreate(UserBase):
password: str
class UserUpdate(BaseModel):
email: EmailStr | None = None
full_name: str | None = None
class UserInDB(UserBase):
id: int
is_active: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class UserPublic(UserBase):
id: int
is_active: bool
UserCreate— incoming POST body (includes password)UserPublic— outgoing response (never includes password)UserInDB— ORM → Pydantic conversion (usesfrom_attributes=True)UserUpdate— PATCH body (all fields optional)
Route handlers return Pydantic schemas, not ORM model instances directly:
@router.get("/users/{user_id}", response_model=UserPublic)
async def get_user(user_id: int, db: AsyncSession = Depends(get_db)):
user = await crud.user.get(db, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user # FastAPI serializes via response_model
## Database Layer Patterns
Document which ORM you use and how the session is wired:
```markdown
## Database
This project uses SQLAlchemy 2.x with async support (asyncpg driver for PostgreSQL).
Engine and session factory in `app/core/database.py`:
```python
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
engine = create_async_engine(settings.DATABASE_URL, echo=False)
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
ORM models extend Base:
# app/models/user.py
from app.core.database import Base
from sqlalchemy import String, Boolean
from sqlalchemy.orm import Mapped, mapped_column
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
Migrations: Alembic. After model changes:
alembic revision --autogenerate -m "description"- Review the generated migration
alembic upgrade head
Never modify existing migrations. Create new ones.
## Testing Conventions
Testing async FastAPI endpoints correctly requires `pytest-asyncio` and `httpx.AsyncClient`. This is one of the most common areas where agents generate code that doesn't match the project setup.
```markdown
## Testing
Test framework: pytest with pytest-asyncio. Test client: httpx AsyncClient
(not Starlette's synchronous TestClient).
### conftest.py structure
```python
# tests/conftest.py
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.main import app
from app.core.database import Base
from app.dependencies import get_db
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest_asyncio.fixture(scope="session")
async def engine():
engine = create_async_engine(TEST_DATABASE_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db(engine):
async with async_sessionmaker(engine)() as session:
yield session
await session.rollback()
@pytest_asyncio.fixture
async def client(db):
async def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as ac:
yield ac
app.dependency_overrides.clear()
Test pattern
# tests/api/v1/test_users.py
import pytest
@pytest.mark.asyncio
async def test_create_user(client: AsyncClient):
response = await client.post(
"/api/v1/users/",
json={"email": "[email protected]", "password": "secret123"}
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "[email protected]"
assert "password" not in data
@pytest.mark.asyncio
async def test_get_user_not_found(client: AsyncClient):
response = await client.get("/api/v1/users/99999")
assert response.status_code == 404
Run tests: pytest tests/ -v
Run with coverage: pytest tests/ --cov=app --cov-report=term-missing
Use AsyncClient — do not use Starlette’s TestClient for async endpoints.
## Authentication Patterns
```markdown
## Authentication
This project uses JWT with OAuth2 password flow. Auth utilities in `app/core/security.py`.
### OAuth2 scheme
```python
# app/core/security.py
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token")
Token creation
from datetime import datetime, timedelta, timezone
from jose import jwt
SECRET_KEY = settings.SECRET_KEY
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
def create_access_token(subject: str | int) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": str(subject), "exp": expire}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
Protected routes
@router.get("/users/me", response_model=UserPublic)
async def get_current_user_profile(
current_user: User = Depends(get_current_user)
):
return current_user
Always use Depends(get_current_user) for protected routes.
Do not verify tokens manually in route handlers.
## Error Handling
```markdown
## Error Handling
Use `HTTPException` for all client errors. Never raise bare Python exceptions in
route handlers.
Standard patterns:
```python
from fastapi import HTTPException, status
# 404 Not Found
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
# 400 Bad Request
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered")
# 401 Unauthorized
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# 403 Forbidden
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
Use status.HTTP_* constants from fastapi — do not hardcode integer status codes.
For validation errors, let Pydantic handle them. FastAPI converts RequestValidationError
to 422 automatically — do not catch and re-raise validation errors unless you need
custom error formatting.
For unexpected server errors, let them propagate. Do not catch generic Exception
in route handlers. Add a global exception handler in main.py if you need custom
500 responses.
## Configuration with pydantic-settings
```markdown
## Configuration
Settings class in `app/config.py` using pydantic-settings:
```python
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# Database
DATABASE_URL: str
# Auth
SECRET_KEY: str
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# App
APP_ENV: str = "development"
DEBUG: bool = False
ALLOWED_ORIGINS: list[str] = ["http://localhost:3000"]
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True
)
settings = Settings()
Import settings like this:
from app.config import settings
Never use os.environ.get() directly in route handlers or models.
Never hardcode configuration values.
Environment variables go in .env (local, gitignored) and .env.example (committed).
One caveat on the `.env` approach: even gitignored, that file holds real database passwords and API keys in plaintext on every developer's machine and CI runner. For anything past a local toy project, keep the actual secrets in a vault and inject them at runtime — [1Password CLI's `op run`](https://1password.partnerlinks.io/z5r1pmkqu36z) loads them into the process environment only while the app runs, so `pydantic-settings` reads them exactly the same way while nothing sensitive sits on disk.
## Complete AGENTS.md Template
Here's a copy-paste-ready AGENTS.md for a FastAPI project. Customize the project-specific sections for your setup.
```markdown
# AGENTS.md
## Project Overview
FastAPI REST API. Python 3.12. PostgreSQL (asyncpg). SQLAlchemy 2.x async.
Alembic for migrations. JWT authentication. pytest with pytest-asyncio for testing.
## Commands
- Start dev server: `uvicorn app.main:app --reload`
- Run tests: `pytest tests/ -v`
- Run tests with coverage: `pytest tests/ --cov=app --cov-report=term-missing`
- Create migration: `alembic revision --autogenerate -m "description"`
- Apply migrations: `alembic upgrade head`
- Lint: `ruff check .`
- Format: `ruff format .`
- Type check: `mypy app/`
## Project Structure
app/
main.py — FastAPI app, middleware, startup events
config.py — pydantic-settings BaseSettings
dependencies.py — Shared Depends() functions
core/
database.py — Engine, SessionLocal, Base
security.py — JWT, password hashing, OAuth2 scheme
models/ — SQLAlchemy ORM models only
schemas/ — Pydantic request/response schemas only
crud/ — Database operations (async, takes db session as arg)
api/
v1/
router.py — include_router() calls
endpoints/ — Route handler modules
## Async Rules
- All route handlers: `async def`
- All CRUD functions: `async def`
- DB driver is async (asyncpg). Never use sync DB calls in async handlers.
- Use `BackgroundTasks` only for fire-and-forget (email, logging).
## Dependency Injection
- Never instantiate DB sessions or services directly in route handlers.
- Use `Depends(get_db)` for database sessions.
- Use `Depends(get_current_user)` for authenticated routes.
- Add new shared dependencies to `app/dependencies.py`.
## Models vs Schemas
- `app/models/` — SQLAlchemy ORM models extending `Base`. Never import Pydantic here.
- `app/schemas/` — Pydantic models for API validation. Never import SQLAlchemy here.
- Schema naming convention: `UserCreate`, `UserPublic`, `UserUpdate`, `UserInDB`.
- `UserInDB` uses `model_config = ConfigDict(from_attributes=True)` for ORM conversion.
## Route Handlers
- Return Pydantic schema instances (FastAPI serializes via `response_model`).
- Use `HTTPException` with `status.HTTP_*` constants for all client errors.
- Never raise bare Python exceptions.
- No database queries in route handlers — always delegate to `crud/`.
## Authentication
- OAuth2 scheme: `OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token")`.
- Protected routes: `Depends(get_current_user)`.
- Never re-implement token verification inline.
## Testing
- Test client: `httpx.AsyncClient` with `ASGITransport`.
- Do NOT use Starlette `TestClient` for async endpoints.
- Test DB: SQLite with aiosqlite (in-memory).
- Override `get_db` dependency in fixtures via `app.dependency_overrides`.
- All test functions: `async def` decorated with `@pytest.mark.asyncio`.
## Configuration
- Settings: `from app.config import settings`.
- Never use `os.environ.get()` directly in handlers or models.
- Env vars: `.env` (local, gitignored), `.env.example` (committed with placeholders).
## Migrations
- Never modify existing Alembic migrations.
- After model changes: `alembic revision --autogenerate -m "description"`, review, then apply.
## What to Avoid
- Sync route handlers when DB is async.
- Direct DB queries in route handlers.
- Pydantic schemas in `app/models/` or ORM models in `app/schemas/`.
- Re-implementing auth logic that exists in `app/core/security.py`.
- Hardcoded config values or `os.environ` calls outside `app/config.py`.
- Using `TestClient` instead of `AsyncClient` in tests.
Complete CLAUDE.md Template
# CLAUDE.md
## Claude-Specific Behavior
When I ask you to add a new endpoint, follow this sequence and check in with me
after each step if anything is ambiguous:
1. ORM model (if new table is needed)
2. Pydantic schemas
3. CRUD functions
4. Route handler
5. Tests
## Before Touching auth/
Always ask me before modifying anything in `app/core/security.py` or the
`get_current_user` dependency. Auth bugs are silent and hard to catch.
## Migration Review
After generating an Alembic migration with `--autogenerate`, always show me the
migration file content before applying it. Autogenerated migrations sometimes
miss column constraints or produce incorrect operations.
## Test Coverage
When adding new routes, always add corresponding tests. Don't leave me a TODO
comment — write the test.
## Response Style
When explaining async vs sync tradeoffs or dependency injection chains, be specific.
"Use async" is not enough — tell me why a specific choice matters for this codebase.
## Useful Context
- The JWT secret is in `.env` as `SECRET_KEY`. Never echo it.
- The test DB is in-memory SQLite. Production is PostgreSQL. If you see behavior
differences between test and prod, check for PostgreSQL-specific SQL in the code.
- `expire_on_commit=False` on our `async_sessionmaker` is intentional — it avoids
lazy-loading after commit in async context.
How Different AI Tools Read These Files
Understanding how each tool processes these files helps you write them more effectively.
Claude Code reads AGENTS.md automatically when you open a project. It walks up the directory tree collecting all AGENTS.md files it encounters, merging them root-to-leaf. CLAUDE.md is read in addition to AGENTS.md and takes precedence for Claude-specific behaviors. Instructions in AGENTS.md shape code generation, not just chat responses — tell Claude to use AsyncClient, and it will use AsyncClient in the code it writes.
Cursor reads .cursorrules by default but also supports AGENTS.md through the “Include AGENTS.md” setting. For FastAPI projects, the most reliable approach is to maintain AGENTS.md as the canonical source and either use Cursor’s AGENTS.md support directly or symlink .cursorrules → AGENTS.md.
GitHub Copilot in VS Code reads .github/copilot-instructions.md. It doesn’t read AGENTS.md natively. If your team uses Copilot, copy the essential conventions into .github/copilot-instructions.md — or maintain both files if you use multiple agents on the project.
OpenAI Codex reads AGENTS.md natively, following the same hierarchical resolution as Claude Code. The template above works for Codex without modification.
For teams using multiple AI tools, the pragmatic approach is: maintain AGENTS.md as the source of truth, then use tool-specific files (.cursorrules, .github/copilot-instructions.md) as thin wrappers that point to or summarize the AGENTS.md content.
Putting It Together
The FastAPI patterns that cause the most AI agent errors — async/sync mixups, dependency injection bypasses, schema/model conflation, wrong test client — are all fixable with a well-written AGENTS.md. The file doesn’t need to be exhaustive. It needs to cover the decisions that are specific to your project and that an agent working from generic Python knowledge would get wrong.
Start with the complete template above, remove sections that don’t apply to your setup (if you’re using SQLModel instead of separate SQLAlchemy + Pydantic, update the models/schemas section accordingly), and add project-specific notes as you catch agents making the same mistakes repeatedly.
The goal is to spend less time reviewing AI-generated code for framework violations and more time reviewing it for logic. An AGENTS.md that encodes your FastAPI conventions gets you there.
You can find more instruction file patterns for other frameworks and tools in our rules collection.
FAQ
Should a FastAPI project use AGENTS.md or CLAUDE.md?
Use AGENTS.md for the technical, prescriptive rules — project structure, async conventions, dependency injection, Pydantic schemas — because it is read by Claude Code, Codex, Cursor, and any agent supporting the spec. CLAUDE.md adds Claude Code-specific behaviors on top. They complement each other rather than compete.
Why do AI coding agents get FastAPI code wrong?
FastAPI rewards following its patterns precisely: async-first handlers, Pydantic validation at every boundary, Depends()-based dependency injection. Agents that don’t know these patterns fall back to generic Python conventions — code that technically works but doesn’t fit the project.
How do I stop an agent from writing blocking calls in async routes?
State your async rules explicitly in AGENTS.md: whether the DB layer is async, which client libraries to use (httpx.AsyncClient, not requests), and that sync I/O inside async def handlers is forbidden. A sync DB call in an async route passes tests but fails in production under load — this is the single highest-value rule to encode.
What should an AGENTS.md for FastAPI include?
The high-leverage sections: project structure and router organization, API versioning, dependency injection patterns, async rules, Pydantic schema conventions, database layer patterns, testing conventions, authentication, error handling, and configuration via pydantic-settings. The complete template above covers all of them.
Should Pydantic schemas and ORM models be the same class?
No. Request/response schemas validate at the API boundary; ORM models represent database state. Agents tend to conflate them — especially with SQLModel — so AGENTS.md should state explicitly that the two layers stay separate.