dotmd
// 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
    CLAUDE.md
// File Content
CLAUDE.md
1# CLAUDE.md — Python + FastAPI
2
3This file contains your project instructions for Claude Code. Apply these
4coding conventions, architecture decisions, and workflow preferences to every
5change you make in this repository.
6
7---
8
9HTTP 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.
10
11## Quick Reference
12
13| 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]"` |
31
32## Project Structure
33
34```
35├── app/
36│ ├── __init__.py
37│ ├── main.py # FastAPI app factory, middleware, lifespan
38│ ├── config.py # Pydantic Settings — all env var parsing
39│ ├── dependencies.py # Shared FastAPI dependencies (get_db, get_current_user)
40│ ├── database.py # SQLAlchemy engine, sessionmaker, Base
41│ ├── models/ # SQLAlchemy ORM models
42│ │ ├── __init__.py
43│ │ ├── base.py # Declarative base, common mixins (timestamps, UUID PK)
44│ │ ├── user.py
45│ │ └── order.py
46│ ├── schemas/ # Pydantic v2 request/response schemas
47│ │ ├── __init__.py
48│ │ ├── user.py
49│ │ └── order.py
50│ ├── services/ # Business logic — no HTTP or FastAPI imports
51│ │ ├── __init__.py
52│ │ ├── user.py
53│ │ └── order.py
54│ ├── routers/ # Route handlers — thin, delegate to services
55│ │ ├── __init__.py
56│ │ ├── user.py
57│ │ └── order.py
58│ ├── middleware/ # Custom middleware (logging, correlation ID)
59│ └── exceptions.py # Domain exceptions + exception handlers
60├── alembic/
61│ ├── env.py
62│ └── versions/
63├── tests/
64│ ├── conftest.py # Fixtures: test client, DB session, factories
65│ ├── factories.py # Polyfactory or factory_boy factories
66│ ├── test_orders.py
67│ └── test_users.py
68├── alembic.ini
69├── pyproject.toml
70├── .env.example
71├── Dockerfile
72└── docker-compose.yml
73```
74
75`app/routers/` → `app/services/` → `app/models/` is the dependency direction. Routers never import models directly; services are the bridge.
76
77## Code Conventions
78
79### Pydantic v2 Schemas
80
81All request/response models use Pydantic v2. Never use Pydantic v1 patterns.
82
83```python
84from pydantic import BaseModel, Field, ConfigDict
85
86class OrderCreate(BaseModel):
87 model_config = ConfigDict(strict=True)
88
89 items: list[OrderItemCreate] = Field(..., min_length=1)
90 shipping_address_id: uuid.UUID
91 note: str | None = Field(None, max_length=500)
92
93class OrderResponse(BaseModel):
94 model_config = ConfigDict(from_attributes=True)
95
96 id: uuid.UUID
97 status: OrderStatus
98 total_cents: int
99 created_at: datetime
100 items: list[OrderItemResponse]
101```
102
103**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.
109
110### Configuration
111
112All config from environment variables via `pydantic-settings`. No `os.getenv()` scattered through code.
113
114```python
115# app/config.py
116from pydantic_settings import BaseSettings
117
118class Settings(BaseSettings):
119 model_config = ConfigDict(env_file=".env", env_file_encoding="utf-8")
120
121 database_url: str
122 redis_url: str = "redis://localhost:6379/0"
123 secret_key: str
124 debug: bool = False
125 allowed_origins: list[str] = ["http://localhost:3000"]
126```
127
128Settings instance created once in `app/main.py` or `app/dependencies.py`, injected via `Depends`. Never hardcode secrets. `.env.example` documents required vars.
129
130### SQLAlchemy 2.0
131
132Use SQLAlchemy 2.0 style throughout. No legacy 1.x patterns.
133
134```python
135# app/models/order.py
136from sqlalchemy import ForeignKey, String
137from sqlalchemy.orm import Mapped, mapped_column, relationship
138from app.models.base import Base, TimestampMixin, UUIDMixin
139
140class Order(UUIDMixin, TimestampMixin, Base):
141 __tablename__ = "orders"
142
143 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)
146
147 user: Mapped["User"] = relationship(back_populates="orders")
148 items: Mapped[list["OrderItem"]] = relationship(back_populates="order", cascade="all, delete-orphan")
149```
150
151**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`.
156
157### Database Sessions & Dependencies
158
159```python
160# app/dependencies.py
161async def get_db() -> AsyncGenerator[AsyncSession, None]:
162 async with async_session_maker() as session:
163 try:
164 yield session
165 await session.commit()
166 except Exception:
167 await session.rollback()
168 raise
169```
170
171One session per request via `Depends(get_db)`. Commit at the dependency level, not in services. Services receive the session as an argument.
172
173### Service Layer
174
175Services are plain functions (or thin classes) that contain all business logic. They never import FastAPI.
176
177```python
178# app/services/order.py
179from sqlalchemy import select
180from sqlalchemy.ext.asyncio import AsyncSession
181
182async 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)
192
193 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.quantity
199 order.items.append(OrderItem(product_id=product.id, quantity=item.quantity, price_cents=product.price_cents))
200
201 order.total_cents = sum(i.price_cents * i.quantity for i in order.items)
202 db.add(order)
203 await db.flush()
204 return order
205```
206
207**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.
212
213### Route Handlers
214
215```python
216# app/routers/order.py
217from fastapi import APIRouter, Depends, status
218
219router = APIRouter(prefix="/orders", tags=["orders"])
220
221@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_id
229 )
230```
231
232**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.
237
238### Exception Handling
239
240Define 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.
241
242### Alembic Migrations
243
244- 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.
247
248## Testing
249
250### Setup
251
252```toml
253# pyproject.toml
254[tool.pytest.ini_options]
255asyncio_mode = "auto"
256addopts = "-v --tb=short -x --strict-markers"
257markers = ["slow: marks tests as slow"]
258```
259
260### Fixtures
261
262```python
263# tests/conftest.py
264import pytest
265from httpx import ASGITransport, AsyncClient
266from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
267
268@pytest.fixture
269async 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 session
276
277@pytest.fixture
278async def client(db_session):
279 app.dependency_overrides[get_db] = lambda: db_session
280 async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
281 yield c
282 app.dependency_overrides.clear()
283```
284
285### Test Patterns
286
287```python
288async 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)
291
292 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)}"})
296
297 assert response.status_code == 201
298 assert response.json()["total_cents"] == 3000
299```
300
301- 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.
305
306## Exploring the Codebase
307
308Before making changes, orient yourself:
309
310```bash
311# Find the FastAPI app instance and lifespan setup
312rg "FastAPI\(" app/main.py
313
314# Find all registered routers
315rg "include_router" app/main.py
316
317# Find all route handlers for a resource
318rg "@router\.(get|post|put|patch|delete)" app/routers/order.py
319
320# Find a model definition
321rg "class Order" app/models/
322
323# Find which services exist for a domain
324ls app/services/
325
326# Find all Depends() injections for a dependency
327rg "Depends\(get_current_user\)" app/routers/
328
329# Check Alembic migration history
330ls -la alembic/versions/
331
332# Find all domain exceptions
333rg "class.*Error.*Exception\)|class.*Error.*DomainError" app/
334
335# See what env vars the app requires
336cat app/config.py
337
338# Check existing test patterns
339head -50 tests/conftest.py
340```
341
342## When to Ask vs. Proceed
343
344**Just do it:**
345- Adding a new route + service that follows an existing pattern
346- Writing or updating tests
347- Fixing lint or type errors
348- Adding Pydantic schemas for a new endpoint
349- Creating an Alembic migration for a model change
350- Adding input validation to an existing schema
351
352**Ask first:**
353- Adding a new dependency to `pyproject.toml`
354- Changing the database session strategy (sync ↔ async)
355- Modifying authentication/authorization middleware
356- Changing the project structure or package layout
357- Switching ORMs, migration tools, or the ASGI server
358- Modifying shared base classes (`Base`, `TimestampMixin`)
359- Any change to `alembic/env.py`
360
361## Git Workflow
362
363- 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 ```bash
367 ruff check .
368 ruff format . --check
369 pytest
370 ```
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.
373
374## Common Pitfalls
375
376- **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