dotmd
// Config Record

>.cursor/rules — Python + FastAPI

Cursor project rules (MDC) for FastAPI backends with layered architecture, SQLAlchemy 2.0, and pytest workflows.

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

>Add this file to your project repository:

  • Cursor
    .cursor/rules/
// File Content
.cursor/rules (MDC)
1---
2description: FastAPI backend conventions with typed services, SQLAlchemy 2.0, and pytest workflows.
3globs:
4alwaysApply: true
5---
6
7# .cursorrules — Python + FastAPI
8
9You are a senior Python developer working in a FastAPI application with SQLAlchemy 2.0, Pydantic v2, and Alembic. You write clean, typed, testable code with a clear service layer. You use `uv` as the package manager. You prefer explicit over magical.
10
11## Quick Reference
12
13| Area | Convention |
14| ----------------- | --------------------------------------------------------------- |
15| Package manager | uv |
16| Framework | FastAPI 0.110+ |
17| Python version | 3.12+ |
18| ORM | SQLAlchemy 2.0 (async, `mapped_column` style) |
19| Validation | Pydantic v2 (`ConfigDict`, `model_validator`) |
20| Migrations | Alembic (async) |
21| Testing | pytest + pytest-asyncio + httpx (`AsyncClient`) |
22| Linting | Ruff (format + lint) |
23| Type checking | mypy (strict) or pyright |
24| Imports | Absolute from project root |
25
26## Project Structure
27
28```
29├── src/app/
30│ ├── main.py # App factory + lifespan
31│ ├── config.py # Settings (pydantic-settings)
32│ ├── database.py # Engine, session factory, Base
33│ ├── dependencies.py # Shared Depends() callables
34│ ├── models/ # SQLAlchemy models
35│ ├── schemas/ # Pydantic request/response schemas
36│ ├── services/ # Business logic (one per domain)
37│ └── api/
38│ ├── router.py # Aggregates all route modules
39│ └── routes/ # Endpoint modules
40├── migrations/
41├── tests/
42│ ├── conftest.py # Fixtures: test DB, client, factories
43│ └── test_*.py
44├── pyproject.toml
45└── uv.lock
46```
47
48## Architecture
49
50Endpoints handle HTTP. Services handle logic. Models handle persistence. They don't bleed into each other.
51
52```
53Request → Route (validate) → Service (logic) → Model (DB) → Service → Schema (serialize) → Response
54```
55
56- Endpoints never import SQLAlchemy models directly — they call services.
57- Services receive `AsyncSession` via DI, never create their own.
58- Services return domain objects or raise domain exceptions — never `HTTPException`.
59- Endpoints catch domain exceptions and map them to HTTP responses.
60
61## Code Patterns
62
63### Settings
64
65```python
66# src/app/config.py
67from pydantic_settings import BaseSettings, SettingsConfigDict
68
69class Settings(BaseSettings):
70 model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
71
72 database_url: str
73 secret_key: str
74 debug: bool = False
75 cors_origins: list[str] = ["http://localhost:5173"]
76
77settings = Settings() # type: ignore[call-arg]
78```
79
80### Database + Dependencies
81
82```python
83# src/app/database.py
84from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
85from sqlalchemy.orm import DeclarativeBase
86from app.config import settings
87
88engine = create_async_engine(settings.database_url, echo=settings.debug)
89async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
90
91class Base(DeclarativeBase):
92 pass
93```
94
95```python
96# src/app/dependencies.py
97from collections.abc import AsyncGenerator
98from sqlalchemy.ext.asyncio import AsyncSession
99from app.database import async_session_factory
100
101async def get_session() -> AsyncGenerator[AsyncSession, None]:
102 async with async_session_factory() as session:
103 yield session
104```
105
106### SQLAlchemy Models
107
108Use `mapped_column` style (SQLAlchemy 2.0). No legacy `Column()`.
109
110```python
111# src/app/models/post.py
112from datetime import datetime
113from uuid import uuid4
114from sqlalchemy import ForeignKey, String, Text
115from sqlalchemy.orm import Mapped, mapped_column, relationship
116from app.database import Base
117
118class Post(Base):
119 __tablename__ = "posts"
120
121 id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
122 title: Mapped[str] = mapped_column(String(200))
123 content: Mapped[str] = mapped_column(Text)
124 status: Mapped[str] = mapped_column(String(20), default="draft")
125 author_id: Mapped[str] = mapped_column(ForeignKey("users.id"))
126 created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
127 updated_at: Mapped[datetime | None] = mapped_column(default=None, onupdate=datetime.utcnow)
128
129 author: Mapped["User"] = relationship(back_populates="posts")
130```
131
132### Pydantic Schemas
133
134Separate schemas for create, update, and response. Never expose the ORM model directly.
135
136```python
137# src/app/schemas/post.py
138from datetime import datetime
139from pydantic import BaseModel, ConfigDict
140
141class PostCreate(BaseModel):
142 title: str
143 content: str
144 status: str = "draft"
145
146class PostUpdate(BaseModel):
147 title: str | None = None
148 content: str | None = None
149 status: str | None = None
150
151class PostResponse(BaseModel):
152 model_config = ConfigDict(from_attributes=True)
153
154 id: str
155 title: str
156 content: str
157 status: str
158 author_id: str
159 created_at: datetime
160 updated_at: datetime | None
161```
162
163### Service Layer
164
165Services are async functions. They receive the session, perform logic, return results or raise domain exceptions.
166
167```python
168# src/app/services/post.py
169from sqlalchemy import select
170from sqlalchemy.ext.asyncio import AsyncSession
171from app.models.post import Post
172from app.schemas.post import PostCreate, PostUpdate
173
174class PostNotFoundError(Exception):
175 def __init__(self, post_id: str) -> None:
176 self.post_id = post_id
177 super().__init__(f"Post {post_id} not found")
178
179async def list_posts(session: AsyncSession, *, status: str | None = None, offset: int = 0, limit: int = 20) -> list[Post]:
180 query = select(Post).offset(offset).limit(limit)
181 if status:
182 query = query.where(Post.status == status)
183 result = await session.execute(query)
184 return list(result.scalars().all())
185
186async def get_post(session: AsyncSession, post_id: str) -> Post:
187 post = await session.get(Post, post_id)
188 if not post:
189 raise PostNotFoundError(post_id)
190 return post
191
192async def create_post(session: AsyncSession, data: PostCreate, *, author_id: str) -> Post:
193 post = Post(**data.model_dump(), author_id=author_id)
194 session.add(post)
195 await session.flush()
196 return post
197```
198
199### Endpoints
200
201Thin wrappers: parse input, call service, return response.
202
203```python
204# src/app/api/routes/posts.py
205from fastapi import APIRouter, Depends, HTTPException, Query
206from sqlalchemy.ext.asyncio import AsyncSession
207from app.dependencies import get_current_user, get_session
208from app.schemas.post import PostCreate, PostResponse, PostUpdate
209from app.services.post import PostNotFoundError, create_post, get_post, list_posts
210
211router = APIRouter(prefix="/posts", tags=["posts"])
212
213@router.get("", response_model=list[PostResponse])
214async def list_posts_endpoint(
215 session: AsyncSession = Depends(get_session),
216 status: str | None = Query(None),
217 offset: int = Query(0, ge=0),
218 limit: int = Query(20, ge=1, le=100),
219):
220 return await list_posts(session, status=status, offset=offset, limit=limit)
221
222@router.post("", response_model=PostResponse, status_code=201)
223async def create_post_endpoint(
224 data: PostCreate,
225 session: AsyncSession = Depends(get_session),
226 current_user=Depends(get_current_user),
227):
228 post = await create_post(session, data, author_id=current_user.id)
229 await session.commit()
230 return post
231
232@router.get("/{post_id}", response_model=PostResponse)
233async def get_post_endpoint(post_id: str, session: AsyncSession = Depends(get_session)):
234 try:
235 return await get_post(session, post_id)
236 except PostNotFoundError:
237 raise HTTPException(status_code=404, detail="Post not found")
238```
239
240### App Factory
241
242```python
243# src/app/main.py
244from contextlib import asynccontextmanager
245from collections.abc import AsyncIterator
246from fastapi import FastAPI
247from fastapi.middleware.cors import CORSMiddleware
248from app.config import settings
249from app.api.router import api_router
250from app.database import engine
251
252@asynccontextmanager
253async def lifespan(app: FastAPI) -> AsyncIterator[None]:
254 yield
255 await engine.dispose()
256
257def create_app() -> FastAPI:
258 app = FastAPI(title="API", lifespan=lifespan, debug=settings.debug)
259 app.add_middleware(CORSMiddleware, allow_origins=settings.cors_origins,
260 allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
261 app.include_router(api_router, prefix="/api/v1")
262 return app
263
264app = create_app()
265```
266
267## Testing
268
269Test at the API level (integration) and service level (unit).
270
271```python
272# tests/conftest.py
273import pytest
274from httpx import ASGITransport, AsyncClient
275from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
276from app.database import Base
277from app.dependencies import get_session
278from app.main import create_app
279
280@pytest.fixture
281async def session():
282 engine = create_async_engine("sqlite+aiosqlite:///./test.db")
283 async with engine.begin() as conn:
284 await conn.run_sync(Base.metadata.create_all)
285 factory = async_sessionmaker(engine, expire_on_commit=False)
286 async with factory() as session:
287 yield session
288 async with engine.begin() as conn:
289 await conn.run_sync(Base.metadata.drop_all)
290 await engine.dispose()
291
292@pytest.fixture
293async def client(session: AsyncSession):
294 app = create_app()
295 app.dependency_overrides[get_session] = lambda: session
296 async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
297 yield c
298```
299
300```python
301# tests/test_posts.py
302import pytest
303pytestmark = pytest.mark.anyio
304
305async def test_create_post(client, auth_headers):
306 resp = await client.post("/api/v1/posts", json={"title": "Test", "content": "Hello"}, headers=auth_headers)
307 assert resp.status_code == 201
308 assert resp.json()["status"] == "draft"
309
310async def test_get_nonexistent_post_returns_404(client):
311 resp = await client.get("/api/v1/posts/nonexistent-id")
312 assert resp.status_code == 404
313```
314
315## Conventions
316
317- **Types:** Annotate all function signatures. Use `str | None` not `Optional[str]`. Use `list[str]` not `List[str]`.
318- **Naming:** snake_case files/functions, PascalCase classes, UPPER_SNAKE_CASE constants.
319- **Async everywhere:** All endpoints and services are `async def`. Never use blocking I/O in async code.
320- **Migrations:** Always review autogenerated Alembic migrations. Name them descriptively: `"add_status_to_posts"`.
321
322## Commands
323
324```bash
325uv run fastapi dev # Dev server (hot reload)
326uv run pytest # Run tests
327uv run pytest --cov=app # Tests with coverage
328uv run ruff check . && uv run ruff format . # Lint + format
329uv run mypy src/ # Type check
330uv run alembic upgrade head # Apply migrations
331uv run alembic revision --autogenerate -m "description"
332```
333
334## When Generating Code
335
3361. **Search the codebase first.** Check for existing services, schemas, and utilities.
3372. **Follow the service layer.** Endpoints → services → models. No shortcuts.
3383. **No new dependencies** without asking.
3394. **Every endpoint needs:** Pydantic validation, proper status codes, error handling, response model.
3405. **Every new model needs:** a migration.
3416. **Type everything.** If mypy would complain, fix it before suggesting.
342