dotmd
// Config Record

>AGENTS.md — Python + FastAPI

Cross-tool AGENTS.md guidance for FastAPI services using Pydantic v2, SQLAlchemy 2.0 async, and layered architecture.

author:
dotmd Team
license:CC0
published:Feb 23, 2026
// Installation

>Add this file to your project repository:

  • Cursor
    AGENTS.md
  • OpenAI Codex
    AGENTS.md
  • Windsurf
    AGENTS.md
  • Cline
    AGENTS.md
  • Google Jules
    AGENTS.md
// File Content
AGENTS.md
1# AGENTS.md — Python + FastAPI
2
3## Project Overview
4
5This is a Python 3.12+ FastAPI application using async SQLAlchemy 2.0, Pydantic v2, and Alembic for migrations. All code uses strict type hints. The API is async-first.
6
7## Project Structure
8
9```
10├── app/
11│ ├── __init__.py
12│ ├── main.py # FastAPI app factory, middleware, exception handlers
13│ ├── config.py # Settings via pydantic-settings
14│ ├── database.py # Engine, async session factory
15│ ├── dependencies.py # Shared Depends callables
16│ ├── models/ # SQLAlchemy ORM models
17│ │ ├── __init__.py
18│ │ └── base.py # DeclarativeBase class
19│ ├── schemas/ # Pydantic v2 request/response models
20│ ├── routers/ # APIRouter modules, one per domain
21│ ├── services/ # Business logic (no HTTP concerns)
22│ └── middleware/ # Custom middleware classes
23├── migrations/ # Alembic
24│ ├── env.py
25│ └── versions/
26├── tests/
27│ ├── conftest.py # Fixtures: async client, test DB session
28│ ├── factories/ # Model factories (factory_boy or manual)
29│ └── routers/ # Test files mirror app/routers/
30├── alembic.ini
31├── pyproject.toml
32└── uv.lock
33```
34
35## Commands
36
37```bash
38uv run fastapi dev # Dev server with reload
39uv run pytest # Run all tests
40uv run pytest tests/routers/test_users.py -x # Single file, stop on first failure
41uv run pytest -k "test_create" # Run by name pattern
42uv run alembic upgrade head # Apply all migrations
43uv run alembic revision --autogenerate -m "add users table"
44uv run ruff check app/ tests/ # Lint
45uv run ruff format app/ tests/ # Format
46uv run mypy app/ # Type check
47```
48
49## FastAPI Patterns
50
51### App Factory (`app/main.py`)
52
53```python
54from fastapi import FastAPI
55from contextlib import asynccontextmanager
56
57@asynccontextmanager
58async def lifespan(app: FastAPI):
59 # Startup: init connection pools, caches
60 yield
61 # Shutdown: dispose engine, close connections
62
63def create_app() -> FastAPI:
64 app = FastAPI(lifespan=lifespan)
65 app.include_router(users.router, prefix="/users", tags=["users"])
66 app.add_exception_handler(AppError, app_error_handler)
67 return app
68
69app = create_app()
70```
71
72Use `lifespan`, not the deprecated `@app.on_event("startup")` / `on_event("shutdown")`.
73
74### Routers
75
76Each router is a module in `app/routers/` with a module-level `router = APIRouter()`. Routers contain only HTTP handling — no business logic, no direct DB queries.
77
78```python
79from fastapi import APIRouter, Depends, status
80from app.dependencies import get_db
81from app.schemas.users import UserCreate, UserResponse
82from app.services.users import UserService
83
84router = APIRouter()
85
86@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
87async def create_user(body: UserCreate, db: AsyncSession = Depends(get_db)):
88 return await UserService(db).create(body)
89```
90
91### Dependency Injection
92
93Dependencies live in `app/dependencies.py`. Use `Annotated` types to avoid repeating `Depends()`:
94
95```python
96from typing import Annotated
97from fastapi import Depends
98from sqlalchemy.ext.asyncio import AsyncSession
99
100async def get_db() -> AsyncGenerator[AsyncSession, None]:
101 async with async_session_factory() as session:
102 yield session
103
104DbSession = Annotated[AsyncSession, Depends(get_db)]
105CurrentUser = Annotated[User, Depends(get_current_user)]
106```
107
108Then in route handlers: `async def get_item(db: DbSession, user: CurrentUser)`.
109
110### Path and Query Parameters
111
112Use Pydantic models for complex query parameters:
113
114```python
115from fastapi import Query
116
117@router.get("/")
118async def list_users(
119 db: DbSession,
120 offset: int = Query(default=0, ge=0),
121 limit: int = Query(default=20, ge=1, le=100),
122):
123 ...
124```
125
126For path params that need validation, use `Annotated[int, Path(gt=0)]` — not bare `int`.
127
128## Pydantic v2 Models
129
130All schemas go in `app/schemas/`. Use Pydantic v2 syntax exclusively.
131
132```python
133from pydantic import BaseModel, ConfigDict, EmailStr, field_validator, computed_field
134
135class UserBase(BaseModel):
136 model_config = ConfigDict(from_attributes=True, strict=True)
137
138 email: EmailStr
139 display_name: str
140
141 @field_validator("display_name")
142 @classmethod
143 def name_not_empty(cls, v: str) -> str:
144 if not v.strip():
145 raise ValueError("display_name must not be blank")
146 return v.strip()
147
148class UserCreate(UserBase):
149 password: str
150
151class UserResponse(UserBase):
152 id: int
153 created_at: datetime
154
155 @computed_field
156 @property
157 def initials(self) -> str:
158 return "".join(w[0].upper() for w in self.display_name.split() if w)
159```
160
161**Critical rules:**
162- `model_config = ConfigDict(...)` — never `class Config:`
163- `@field_validator` — never `@validator`
164- `@model_validator(mode="before")` or `mode="after"` — never `@root_validator`
165- `from_attributes=True` — never `orm_mode = True`
166- Use `model_dump()` and `model_validate()` — never `.dict()` or `.from_orm()`
167
168### Separate Create / Update / Response Schemas
169
170Every resource gets at least three schemas. `Update` schemas use `field | None = None` for partial updates:
171
172```python
173class UserUpdate(BaseModel):
174 display_name: str | None = None
175 email: EmailStr | None = None
176```
177
178## SQLAlchemy 2.0 (Async)
179
180### Base Model (`app/models/base.py`)
181
182```python
183from datetime import datetime
184from sqlalchemy import func
185from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
186
187class Base(DeclarativeBase):
188 pass
189
190class TimestampMixin:
191 created_at: Mapped[datetime] = mapped_column(server_default=func.now())
192 updated_at: Mapped[datetime] = mapped_column(
193 server_default=func.now(), onupdate=func.now()
194 )
195```
196
197### ORM Models
198
199```python
200from sqlalchemy import String, ForeignKey
201from sqlalchemy.orm import Mapped, mapped_column, relationship
202from app.models.base import Base, TimestampMixin
203
204class User(TimestampMixin, Base):
205 __tablename__ = "users"
206
207 id: Mapped[int] = mapped_column(primary_key=True)
208 email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
209 display_name: Mapped[str] = mapped_column(String(100))
210 hashed_password: Mapped[str] = mapped_column(String(255))
211
212 posts: Mapped[list["Post"]] = relationship(back_populates="author", lazy="selectin")
213```
214
215**Critical rules:**
216- `Mapped[type]` + `mapped_column()` — never `Column()`
217- `class Base(DeclarativeBase)` — never `declarative_base()`
218- `Mapped[str]` implies `NOT NULL`. Use `Mapped[str | None]` for nullable.
219- Set `lazy="selectin"` or `lazy="raise"` on relationships. Never rely on lazy loading with async sessions — it raises errors.
220
221### Database Setup (`app/database.py`)
222
223```python
224from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
225
226engine = create_async_engine(settings.database_url, echo=False)
227async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
228```
229
230Use `asyncpg` driver: `postgresql+asyncpg://...`. Never use `psycopg2` with async.
231
232### Query Patterns
233
234```python
235from sqlalchemy import select
236
237# Single record
238stmt = select(User).where(User.id == user_id)
239result = await db.execute(stmt)
240user = result.scalar_one_or_none()
241
242# List with filtering
243stmt = select(User).where(User.is_active.is_(True)).offset(offset).limit(limit)
244result = await db.execute(stmt)
245users = result.scalars().all()
246```
247
248Always use `select()` — never `db.query()` (that's the sync/1.x API).
249
250## Alembic
251
252Alembic is configured for async in `migrations/env.py`. Target metadata is `Base.metadata` imported from `app/models/base.py`. All models must be imported in `app/models/__init__.py` so autogenerate detects them.
253
254```python
255# app/models/__init__.py — import all models so Alembic sees them
256from app.models.base import Base
257from app.models.user import User
258from app.models.post import Post
259```
260
261When generating a migration, always review the generated file. Autogenerate misses: index name changes, data migrations, enum alterations, and partial indexes.
262
263## Error Handling
264
265Define domain exceptions in `app/errors.py`. Map them to HTTP responses via exception handlers — don't raise `HTTPException` from service code.
266
267```python
268# app/errors.py
269class AppError(Exception):
270 def __init__(self, message: str, code: str):
271 self.message = message
272 self.code = code
273
274class NotFoundError(AppError):
275 def __init__(self, resource: str, id: int | str):
276 super().__init__(f"{resource} {id} not found", code="not_found")
277
278# app/main.py
279from fastapi.responses import JSONResponse
280
281async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
282 status_map = {"not_found": 404, "conflict": 409, "forbidden": 403}
283 return JSONResponse(
284 status_code=status_map.get(exc.code, 400),
285 content={"error": exc.code, "message": exc.message},
286 )
287```
288
289Use `HTTPException` only for auth/permission checks at the router level. Services raise domain errors.
290
291## Testing
292
293Tests use `pytest` with `pytest-asyncio` and `httpx.AsyncClient`.
294
295### Fixtures (`tests/conftest.py`)
296
297```python
298import pytest
299from httpx import ASGITransport, AsyncClient
300from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
301from app.main import create_app
302from app.models.base import Base
303from app.dependencies import get_db
304
305@pytest.fixture
306async def db_session():
307 engine = create_async_engine("sqlite+aiosqlite:///:memory:")
308 async with engine.begin() as conn:
309 await conn.run_sync(Base.metadata.create_all)
310 session_factory = async_sessionmaker(engine, expire_on_commit=False)
311 async with session_factory() as session:
312 yield session
313 await engine.dispose()
314
315@pytest.fixture
316async def client(db_session):
317 app = create_app()
318 app.dependency_overrides[get_db] = lambda: db_session
319 transport = ASGITransport(app=app)
320 async with AsyncClient(transport=transport, base_url="http://test") as ac:
321 yield ac
322```
323
324### Test Style
325
326```python
327import pytest
328from httpx import AsyncClient
329
330async def test_create_user(client: AsyncClient):
331 response = await client.post("/users/", json={
332 "email": "dev@example.com",
333 "display_name": "Dev User",
334 "password": "s3cret!pass",
335 })
336 assert response.status_code == 201
337 data = response.json()
338 assert data["email"] == "dev@example.com"
339 assert "id" in data
340 assert "password" not in data
341```
342
343Configure `pytest-asyncio` in `pyproject.toml`:
344
345```toml
346[tool.pytest.ini_options]
347asyncio_mode = "auto"
348```
349
350Test files mirror the app structure: `tests/routers/test_users.py` tests `app/routers/users.py`.
351
352## Settings (`app/config.py`)
353
354```python
355from pydantic_settings import BaseSettings, SettingsConfigDict
356
357class Settings(BaseSettings):
358 model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
359
360 database_url: str
361 secret_key: str
362 debug: bool = False
363 allowed_origins: list[str] = ["http://localhost:3000"]
364
365settings = Settings()
366```
367
368Use `pydantic-settings`, not `python-dotenv` with manual `os.getenv()` calls. Type coercion and validation happen automatically.
369
370## Anti-Patterns to Avoid
371
3721. **No `db.query()`** — use `select()` statements with `db.execute()`
3732. **No `Column()`** — use `Mapped[type]` with `mapped_column()`
3743. **No `class Config:` in Pydantic** — use `model_config = ConfigDict(...)`
3754. **No `@validator`** — use `@field_validator` with `@classmethod`
3765. **No `orm_mode`** — use `from_attributes=True` in `ConfigDict`
3776. **No sync DB calls** — use `async_sessionmaker`, `AsyncSession`, `await db.execute()`
3787. **No lazy loading with async** — set explicit `lazy="selectin"` or `lazy="raise"`
3798. **No business logic in routers** — routers call services, services call the DB
3809. **No `@app.on_event`** — use `lifespan` context manager
38110. **No `from pydantic import validator`** — that's v1, use `field_validator`
38211. **No bare `dict` returns** — always set `response_model` on endpoints
38312. **No string status codes** — use `status.HTTP_201_CREATED` from `fastapi`
384