// 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--path=
AGENTS.md - OpenAI Codex--path=
AGENTS.md - Windsurf--path=
AGENTS.md - Cline--path=
AGENTS.md - Google Jules--path=
AGENTS.md
// File Content
AGENTS.md
1# AGENTS.md — Python + FastAPI23## Project Overview45This 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.67## Project Structure89```10├── app/11│ ├── __init__.py12│ ├── main.py # FastAPI app factory, middleware, exception handlers13│ ├── config.py # Settings via pydantic-settings14│ ├── database.py # Engine, async session factory15│ ├── dependencies.py # Shared Depends callables16│ ├── models/ # SQLAlchemy ORM models17│ │ ├── __init__.py18│ │ └── base.py # DeclarativeBase class19│ ├── schemas/ # Pydantic v2 request/response models20│ ├── routers/ # APIRouter modules, one per domain21│ ├── services/ # Business logic (no HTTP concerns)22│ └── middleware/ # Custom middleware classes23├── migrations/ # Alembic24│ ├── env.py25│ └── versions/26├── tests/27│ ├── conftest.py # Fixtures: async client, test DB session28│ ├── factories/ # Model factories (factory_boy or manual)29│ └── routers/ # Test files mirror app/routers/30├── alembic.ini31├── pyproject.toml32└── uv.lock33```3435## Commands3637```bash38uv run fastapi dev # Dev server with reload39uv run pytest # Run all tests40uv run pytest tests/routers/test_users.py -x # Single file, stop on first failure41uv run pytest -k "test_create" # Run by name pattern42uv run alembic upgrade head # Apply all migrations43uv run alembic revision --autogenerate -m "add users table"44uv run ruff check app/ tests/ # Lint45uv run ruff format app/ tests/ # Format46uv run mypy app/ # Type check47```4849## FastAPI Patterns5051### App Factory (`app/main.py`)5253```python54from fastapi import FastAPI55from contextlib import asynccontextmanager5657@asynccontextmanager58async def lifespan(app: FastAPI):59 # Startup: init connection pools, caches60 yield61 # Shutdown: dispose engine, close connections6263def 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 app6869app = create_app()70```7172Use `lifespan`, not the deprecated `@app.on_event("startup")` / `on_event("shutdown")`.7374### Routers7576Each 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.7778```python79from fastapi import APIRouter, Depends, status80from app.dependencies import get_db81from app.schemas.users import UserCreate, UserResponse82from app.services.users import UserService8384router = APIRouter()8586@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```9091### Dependency Injection9293Dependencies live in `app/dependencies.py`. Use `Annotated` types to avoid repeating `Depends()`:9495```python96from typing import Annotated97from fastapi import Depends98from sqlalchemy.ext.asyncio import AsyncSession99100async def get_db() -> AsyncGenerator[AsyncSession, None]:101 async with async_session_factory() as session:102 yield session103104DbSession = Annotated[AsyncSession, Depends(get_db)]105CurrentUser = Annotated[User, Depends(get_current_user)]106```107108Then in route handlers: `async def get_item(db: DbSession, user: CurrentUser)`.109110### Path and Query Parameters111112Use Pydantic models for complex query parameters:113114```python115from fastapi import Query116117@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```125126For path params that need validation, use `Annotated[int, Path(gt=0)]` — not bare `int`.127128## Pydantic v2 Models129130All schemas go in `app/schemas/`. Use Pydantic v2 syntax exclusively.131132```python133from pydantic import BaseModel, ConfigDict, EmailStr, field_validator, computed_field134135class UserBase(BaseModel):136 model_config = ConfigDict(from_attributes=True, strict=True)137138 email: EmailStr139 display_name: str140141 @field_validator("display_name")142 @classmethod143 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()147148class UserCreate(UserBase):149 password: str150151class UserResponse(UserBase):152 id: int153 created_at: datetime154155 @computed_field156 @property157 def initials(self) -> str:158 return "".join(w[0].upper() for w in self.display_name.split() if w)159```160161**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()`167168### Separate Create / Update / Response Schemas169170Every resource gets at least three schemas. `Update` schemas use `field | None = None` for partial updates:171172```python173class UserUpdate(BaseModel):174 display_name: str | None = None175 email: EmailStr | None = None176```177178## SQLAlchemy 2.0 (Async)179180### Base Model (`app/models/base.py`)181182```python183from datetime import datetime184from sqlalchemy import func185from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column186187class Base(DeclarativeBase):188 pass189190class 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```196197### ORM Models198199```python200from sqlalchemy import String, ForeignKey201from sqlalchemy.orm import Mapped, mapped_column, relationship202from app.models.base import Base, TimestampMixin203204class User(TimestampMixin, Base):205 __tablename__ = "users"206207 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))211212 posts: Mapped[list["Post"]] = relationship(back_populates="author", lazy="selectin")213```214215**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.220221### Database Setup (`app/database.py`)222223```python224from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker225226engine = create_async_engine(settings.database_url, echo=False)227async_session_factory = async_sessionmaker(engine, expire_on_commit=False)228```229230Use `asyncpg` driver: `postgresql+asyncpg://...`. Never use `psycopg2` with async.231232### Query Patterns233234```python235from sqlalchemy import select236237# Single record238stmt = select(User).where(User.id == user_id)239result = await db.execute(stmt)240user = result.scalar_one_or_none()241242# List with filtering243stmt = select(User).where(User.is_active.is_(True)).offset(offset).limit(limit)244result = await db.execute(stmt)245users = result.scalars().all()246```247248Always use `select()` — never `db.query()` (that's the sync/1.x API).249250## Alembic251252Alembic 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.253254```python255# app/models/__init__.py — import all models so Alembic sees them256from app.models.base import Base257from app.models.user import User258from app.models.post import Post259```260261When generating a migration, always review the generated file. Autogenerate misses: index name changes, data migrations, enum alterations, and partial indexes.262263## Error Handling264265Define domain exceptions in `app/errors.py`. Map them to HTTP responses via exception handlers — don't raise `HTTPException` from service code.266267```python268# app/errors.py269class AppError(Exception):270 def __init__(self, message: str, code: str):271 self.message = message272 self.code = code273274class NotFoundError(AppError):275 def __init__(self, resource: str, id: int | str):276 super().__init__(f"{resource} {id} not found", code="not_found")277278# app/main.py279from fastapi.responses import JSONResponse280281async 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```288289Use `HTTPException` only for auth/permission checks at the router level. Services raise domain errors.290291## Testing292293Tests use `pytest` with `pytest-asyncio` and `httpx.AsyncClient`.294295### Fixtures (`tests/conftest.py`)296297```python298import pytest299from httpx import ASGITransport, AsyncClient300from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker301from app.main import create_app302from app.models.base import Base303from app.dependencies import get_db304305@pytest.fixture306async 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 session313 await engine.dispose()314315@pytest.fixture316async def client(db_session):317 app = create_app()318 app.dependency_overrides[get_db] = lambda: db_session319 transport = ASGITransport(app=app)320 async with AsyncClient(transport=transport, base_url="http://test") as ac:321 yield ac322```323324### Test Style325326```python327import pytest328from httpx import AsyncClient329330async 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 == 201337 data = response.json()338 assert data["email"] == "dev@example.com"339 assert "id" in data340 assert "password" not in data341```342343Configure `pytest-asyncio` in `pyproject.toml`:344345```toml346[tool.pytest.ini_options]347asyncio_mode = "auto"348```349350Test files mirror the app structure: `tests/routers/test_users.py` tests `app/routers/users.py`.351352## Settings (`app/config.py`)353354```python355from pydantic_settings import BaseSettings, SettingsConfigDict356357class Settings(BaseSettings):358 model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")359360 database_url: str361 secret_key: str362 debug: bool = False363 allowed_origins: list[str] = ["http://localhost:3000"]364365settings = Settings()366```367368Use `pydantic-settings`, not `python-dotenv` with manual `os.getenv()` calls. Type coercion and validation happen automatically.369370## Anti-Patterns to Avoid3713721. **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 DB3809. **No `@app.on_event`** — use `lifespan` context manager38110. **No `from pydantic import validator`** — that's v1, use `field_validator`38211. **No bare `dict` returns** — always set `response_model` on endpoints38312. **No string status codes** — use `status.HTTP_201_CREATED` from `fastapi`384