// Config Record
>Python + FastAPI
Claude Code instructions for production FastAPI repositories with DI, services, and Alembic discipline.
author:
dotmd Team
license:CC0
published:Feb 19, 2026
// Installation
>Add this file to your project repository:
- Claude Code--path=
CLAUDE.md
// File Content
CLAUDE.md
1# CLAUDE.md — Python + FastAPI23This file contains your project instructions for Claude Code. Apply these4coding conventions, architecture decisions, and workflow preferences to every5change you make in this repository.67---89HTTP API in Python with FastAPI. Prioritize type safety via Pydantic v2, thin route handlers, service-layer business logic, and async-first patterns. Every change must pass `ruff check . && ruff format . --check && pytest` before committing.1011## Quick Reference1213| Task | Command |14|---|---|15| Run dev server | `uvicorn app.main:app --reload` |16| Run all tests | `pytest` |17| Run single test | `pytest tests/test_orders.py::test_create_order -x` |18| Run tests (parallel) | `pytest -n auto` |19| Lint | `ruff check .` |20| Lint (fix) | `ruff check . --fix` |21| Format | `ruff format .` |22| Format (check only) | `ruff format . --check` |23| Type check | `mypy .` |24| Alembic: create migration | `alembic revision --autogenerate -m "description"` |25| Alembic: migrate up | `alembic upgrade head` |26| Alembic: migrate down one | `alembic downgrade -1` |27| Alembic: show history | `alembic history --verbose` |28| Docker up (full stack) | `docker compose up -d` |29| Docker rebuild | `docker compose up -d --build` |30| Install deps | `uv sync` or `pip install -e ".[dev]"` |3132## Project Structure3334```35├── app/36│ ├── __init__.py37│ ├── main.py # FastAPI app factory, middleware, lifespan38│ ├── config.py # Pydantic Settings — all env var parsing39│ ├── dependencies.py # Shared FastAPI dependencies (get_db, get_current_user)40│ ├── database.py # SQLAlchemy engine, sessionmaker, Base41│ ├── models/ # SQLAlchemy ORM models42│ │ ├── __init__.py43│ │ ├── base.py # Declarative base, common mixins (timestamps, UUID PK)44│ │ ├── user.py45│ │ └── order.py46│ ├── schemas/ # Pydantic v2 request/response schemas47│ │ ├── __init__.py48│ │ ├── user.py49│ │ └── order.py50│ ├── services/ # Business logic — no HTTP or FastAPI imports51│ │ ├── __init__.py52│ │ ├── user.py53│ │ └── order.py54│ ├── routers/ # Route handlers — thin, delegate to services55│ │ ├── __init__.py56│ │ ├── user.py57│ │ └── order.py58│ ├── middleware/ # Custom middleware (logging, correlation ID)59│ └── exceptions.py # Domain exceptions + exception handlers60├── alembic/61│ ├── env.py62│ └── versions/63├── tests/64│ ├── conftest.py # Fixtures: test client, DB session, factories65│ ├── factories.py # Polyfactory or factory_boy factories66│ ├── test_orders.py67│ └── test_users.py68├── alembic.ini69├── pyproject.toml70├── .env.example71├── Dockerfile72└── docker-compose.yml73```7475`app/routers/` → `app/services/` → `app/models/` is the dependency direction. Routers never import models directly; services are the bridge.7677## Code Conventions7879### Pydantic v2 Schemas8081All request/response models use Pydantic v2. Never use Pydantic v1 patterns.8283```python84from pydantic import BaseModel, Field, ConfigDict8586class OrderCreate(BaseModel):87 model_config = ConfigDict(strict=True)8889 items: list[OrderItemCreate] = Field(..., min_length=1)90 shipping_address_id: uuid.UUID91 note: str | None = Field(None, max_length=500)9293class OrderResponse(BaseModel):94 model_config = ConfigDict(from_attributes=True)9596 id: uuid.UUID97 status: OrderStatus98 total_cents: int99 created_at: datetime100 items: list[OrderItemResponse]101```102103**Rules:**104- Use `model_config = ConfigDict(from_attributes=True)` for ORM-backed responses — never the old `class Config: orm_mode = True`.105- Use `Field(...)` for required fields with constraints. Use `Field(None)` for optional fields.106- Use `str | None` syntax, not `Optional[str]`.107- Enums for status fields: use Python `enum.StrEnum` (3.11+) or `str, Enum`.108- Never reuse a request schema as a response schema. Separate them even if they look similar.109110### Configuration111112All config from environment variables via `pydantic-settings`. No `os.getenv()` scattered through code.113114```python115# app/config.py116from pydantic_settings import BaseSettings117118class Settings(BaseSettings):119 model_config = ConfigDict(env_file=".env", env_file_encoding="utf-8")120121 database_url: str122 redis_url: str = "redis://localhost:6379/0"123 secret_key: str124 debug: bool = False125 allowed_origins: list[str] = ["http://localhost:3000"]126```127128Settings instance created once in `app/main.py` or `app/dependencies.py`, injected via `Depends`. Never hardcode secrets. `.env.example` documents required vars.129130### SQLAlchemy 2.0131132Use SQLAlchemy 2.0 style throughout. No legacy 1.x patterns.133134```python135# app/models/order.py136from sqlalchemy import ForeignKey, String137from sqlalchemy.orm import Mapped, mapped_column, relationship138from app.models.base import Base, TimestampMixin, UUIDMixin139140class Order(UUIDMixin, TimestampMixin, Base):141 __tablename__ = "orders"142143 user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))144 status: Mapped[str] = mapped_column(String(20), default="pending")145 total_cents: Mapped[int] = mapped_column(default=0)146147 user: Mapped["User"] = relationship(back_populates="orders")148 items: Mapped[list["OrderItem"]] = relationship(back_populates="order", cascade="all, delete-orphan")149```150151**Rules:**152- Use `Mapped[]` type annotations with `mapped_column()`. Never use `Column()` directly.153- Base mixin for UUID primary keys and timestamps — define once in `app/models/base.py`.154- Relationships always have `back_populates`, never `backref`.155- Use async sessions (`AsyncSession`) if the project uses `asyncpg`. Use sync sessions if it uses `psycopg2`.156157### Database Sessions & Dependencies158159```python160# app/dependencies.py161async def get_db() -> AsyncGenerator[AsyncSession, None]:162 async with async_session_maker() as session:163 try:164 yield session165 await session.commit()166 except Exception:167 await session.rollback()168 raise169```170171One session per request via `Depends(get_db)`. Commit at the dependency level, not in services. Services receive the session as an argument.172173### Service Layer174175Services are plain functions (or thin classes) that contain all business logic. They never import FastAPI.176177```python178# app/services/order.py179from sqlalchemy import select180from sqlalchemy.ext.asyncio import AsyncSession181182async def create_order(183 *,184 db: AsyncSession,185 user_id: uuid.UUID,186 items: list[OrderItemCreate],187 shipping_address_id: uuid.UUID,188) -> Order:189 address = await db.get(ShippingAddress, shipping_address_id)190 if not address or address.user_id != user_id:191 raise AddressNotFoundError(shipping_address_id)192193 order = Order(user_id=user_id, status="pending")194 for item in items:195 product = await db.get(Product, item.product_id)196 if not product or product.stock < item.quantity:197 raise InsufficientStockError(item.product_id)198 product.stock -= item.quantity199 order.items.append(OrderItem(product_id=product.id, quantity=item.quantity, price_cents=product.price_cents))200201 order.total_cents = sum(i.price_cents * i.quantity for i in order.items)202 db.add(order)203 await db.flush()204 return order205```206207**Rules:**208- Use keyword-only arguments (`*`) to force named calls.209- Services raise domain exceptions (`OrderNotFoundError`, `InsufficientStockError`) — never `HTTPException`.210- Services receive `AsyncSession` (or `Session`), not request objects.211- Use `db.flush()` (not `db.commit()`) — the dependency commits.212213### Route Handlers214215```python216# app/routers/order.py217from fastapi import APIRouter, Depends, status218219router = APIRouter(prefix="/orders", tags=["orders"])220221@router.post("/", response_model=OrderResponse, status_code=status.HTTP_201_CREATED)222async def create_order_endpoint(223 body: OrderCreate,224 db: AsyncSession = Depends(get_db),225 current_user: User = Depends(get_current_user),226) -> Order:227 return await create_order(228 db=db, user_id=current_user.id, items=body.items, shipping_address_id=body.shipping_address_id229 )230```231232**Rules:**233- Handlers are thin: parse request → call service → return response. No business logic.234- Always use `response_model` for type safety and automatic serialization.235- Always set explicit `status_code` for non-200 responses.236- Domain exceptions are caught by exception handlers (registered in `app/main.py`), not by try/except in routes.237238### Exception Handling239240Define domain exceptions in `app/exceptions.py` (`NotFoundError`, `ConflictError`, etc.) inheriting from a base `DomainError`. Register exception handlers in `main.py` that map domain errors to HTTP status codes with a consistent error envelope: `{"error": {"code": "not_found", "message": "..."}}`. Never raise raw `HTTPException` from services.241242### Alembic Migrations243244- Every model change requires a migration. Run `alembic revision --autogenerate -m "description"` and **inspect the generated file** — autogenerate misses renames (shows as drop + add), custom types, and data migrations.245- One migration per logical change. Never edit a migration applied in production — create a new one.246- Test migrations: `alembic upgrade head && alembic downgrade base && alembic upgrade head` should work cleanly.247248## Testing249250### Setup251252```toml253# pyproject.toml254[tool.pytest.ini_options]255asyncio_mode = "auto"256addopts = "-v --tb=short -x --strict-markers"257markers = ["slow: marks tests as slow"]258```259260### Fixtures261262```python263# tests/conftest.py264import pytest265from httpx import ASGITransport, AsyncClient266from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker267268@pytest.fixture269async def db_session():270 engine = create_async_engine("sqlite+aiosqlite:///:memory:")271 async with engine.begin() as conn:272 await conn.run_sync(Base.metadata.create_all)273 session_maker = async_sessionmaker(engine, expire_on_commit=False)274 async with session_maker() as session:275 yield session276277@pytest.fixture278async def client(db_session):279 app.dependency_overrides[get_db] = lambda: db_session280 async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:281 yield c282 app.dependency_overrides.clear()283```284285### Test Patterns286287```python288async def test_create_order_success(client, db_session, user_factory, product_factory):289 user = await user_factory(db_session)290 product = await product_factory(db_session, stock=10, price_cents=1500)291292 response = await client.post("/orders/", json={293 "items": [{"product_id": str(product.id), "quantity": 2}],294 "shipping_address_id": str(user.default_address_id),295 }, headers={"Authorization": f"Bearer {create_token(user)}"})296297 assert response.status_code == 201298 assert response.json()["total_cents"] == 3000299```300301- Use `httpx.AsyncClient` with `ASGITransport` — not `TestClient` (which is sync).302- Override dependencies via `app.dependency_overrides` in fixtures.303- Test services directly for business logic. Test routes for HTTP-level behavior.304- Use `polyfactory` or `factory-boy` for test data. Mock external services — never call real APIs in tests.305306## Exploring the Codebase307308Before making changes, orient yourself:309310```bash311# Find the FastAPI app instance and lifespan setup312rg "FastAPI\(" app/main.py313314# Find all registered routers315rg "include_router" app/main.py316317# Find all route handlers for a resource318rg "@router\.(get|post|put|patch|delete)" app/routers/order.py319320# Find a model definition321rg "class Order" app/models/322323# Find which services exist for a domain324ls app/services/325326# Find all Depends() injections for a dependency327rg "Depends\(get_current_user\)" app/routers/328329# Check Alembic migration history330ls -la alembic/versions/331332# Find all domain exceptions333rg "class.*Error.*Exception\)|class.*Error.*DomainError" app/334335# See what env vars the app requires336cat app/config.py337338# Check existing test patterns339head -50 tests/conftest.py340```341342## When to Ask vs. Proceed343344**Just do it:**345- Adding a new route + service that follows an existing pattern346- Writing or updating tests347- Fixing lint or type errors348- Adding Pydantic schemas for a new endpoint349- Creating an Alembic migration for a model change350- Adding input validation to an existing schema351352**Ask first:**353- Adding a new dependency to `pyproject.toml`354- Changing the database session strategy (sync ↔ async)355- Modifying authentication/authorization middleware356- Changing the project structure or package layout357- Switching ORMs, migration tools, or the ASGI server358- Modifying shared base classes (`Base`, `TimestampMixin`)359- Any change to `alembic/env.py`360361## Git Workflow362363- Branch from `main`. Name: `feat/<thing>`, `fix/<thing>`, `chore/<thing>`.364- Write clear commit messages: imperative mood, reference ticket if one exists.365- Before pushing, always run:366 ```bash367 ruff check .368 ruff format . --check369 pytest370 ```371- If tests fail, fix them before committing. Never skip or `@pytest.mark.skip` without a reason.372- One logical change per commit. Separate migrations from application code commits.373374## Common Pitfalls375376- **Pydantic v1 patterns.** Never use `class Config: orm_mode = True`, `@validator`, or `Field(None, ...)` with v1 syntax. Use `model_config = ConfigDict(from_attributes=True)`, `@field_validator`, and `str | None = None`.377- **Forgetting `await`.** Missed awaits on async DB calls fail silently or return coroutines. If a test returns unexpected types, check for missing `await`.378- **N+1 queries.** Use `selectinload()` or `joinedload()` for relationships accessed in loops. Check the SQL log in tests.379- **Committing in services.** Services call `db.flush()`, not `db.commit()`. The session dependency handles the commit.380- **Mutable defaults.** Never use `default=[]` or `default={}` in function signatures or Pydantic fields. Use `default_factory=list`.381- **Importing between routers.** Routers should be independent. Shared logic goes in services; shared types go in schemas.382- **Background tasks and DB sessions.** A `BackgroundTask` runs after the response, when the request session is closed. If it needs DB access, create a new session inside the task.383