// 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--path=
.cursor/rules/
// File Content
.cursor/rules (MDC)
1---2description: FastAPI backend conventions with typed services, SQLAlchemy 2.0, and pytest workflows.3globs:4alwaysApply: true5---67# .cursorrules — Python + FastAPI89You 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.1011## Quick Reference1213| 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 |2526## Project Structure2728```29├── src/app/30│ ├── main.py # App factory + lifespan31│ ├── config.py # Settings (pydantic-settings)32│ ├── database.py # Engine, session factory, Base33│ ├── dependencies.py # Shared Depends() callables34│ ├── models/ # SQLAlchemy models35│ ├── schemas/ # Pydantic request/response schemas36│ ├── services/ # Business logic (one per domain)37│ └── api/38│ ├── router.py # Aggregates all route modules39│ └── routes/ # Endpoint modules40├── migrations/41├── tests/42│ ├── conftest.py # Fixtures: test DB, client, factories43│ └── test_*.py44├── pyproject.toml45└── uv.lock46```4748## Architecture4950Endpoints handle HTTP. Services handle logic. Models handle persistence. They don't bleed into each other.5152```53Request → Route (validate) → Service (logic) → Model (DB) → Service → Schema (serialize) → Response54```5556- 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.6061## Code Patterns6263### Settings6465```python66# src/app/config.py67from pydantic_settings import BaseSettings, SettingsConfigDict6869class Settings(BaseSettings):70 model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")7172 database_url: str73 secret_key: str74 debug: bool = False75 cors_origins: list[str] = ["http://localhost:5173"]7677settings = Settings() # type: ignore[call-arg]78```7980### Database + Dependencies8182```python83# src/app/database.py84from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine85from sqlalchemy.orm import DeclarativeBase86from app.config import settings8788engine = create_async_engine(settings.database_url, echo=settings.debug)89async_session_factory = async_sessionmaker(engine, expire_on_commit=False)9091class Base(DeclarativeBase):92 pass93```9495```python96# src/app/dependencies.py97from collections.abc import AsyncGenerator98from sqlalchemy.ext.asyncio import AsyncSession99from app.database import async_session_factory100101async def get_session() -> AsyncGenerator[AsyncSession, None]:102 async with async_session_factory() as session:103 yield session104```105106### SQLAlchemy Models107108Use `mapped_column` style (SQLAlchemy 2.0). No legacy `Column()`.109110```python111# src/app/models/post.py112from datetime import datetime113from uuid import uuid4114from sqlalchemy import ForeignKey, String, Text115from sqlalchemy.orm import Mapped, mapped_column, relationship116from app.database import Base117118class Post(Base):119 __tablename__ = "posts"120121 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)128129 author: Mapped["User"] = relationship(back_populates="posts")130```131132### Pydantic Schemas133134Separate schemas for create, update, and response. Never expose the ORM model directly.135136```python137# src/app/schemas/post.py138from datetime import datetime139from pydantic import BaseModel, ConfigDict140141class PostCreate(BaseModel):142 title: str143 content: str144 status: str = "draft"145146class PostUpdate(BaseModel):147 title: str | None = None148 content: str | None = None149 status: str | None = None150151class PostResponse(BaseModel):152 model_config = ConfigDict(from_attributes=True)153154 id: str155 title: str156 content: str157 status: str158 author_id: str159 created_at: datetime160 updated_at: datetime | None161```162163### Service Layer164165Services are async functions. They receive the session, perform logic, return results or raise domain exceptions.166167```python168# src/app/services/post.py169from sqlalchemy import select170from sqlalchemy.ext.asyncio import AsyncSession171from app.models.post import Post172from app.schemas.post import PostCreate, PostUpdate173174class PostNotFoundError(Exception):175 def __init__(self, post_id: str) -> None:176 self.post_id = post_id177 super().__init__(f"Post {post_id} not found")178179async 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())185186async 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 post191192async 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 post197```198199### Endpoints200201Thin wrappers: parse input, call service, return response.202203```python204# src/app/api/routes/posts.py205from fastapi import APIRouter, Depends, HTTPException, Query206from sqlalchemy.ext.asyncio import AsyncSession207from app.dependencies import get_current_user, get_session208from app.schemas.post import PostCreate, PostResponse, PostUpdate209from app.services.post import PostNotFoundError, create_post, get_post, list_posts210211router = APIRouter(prefix="/posts", tags=["posts"])212213@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)221222@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 post231232@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```239240### App Factory241242```python243# src/app/main.py244from contextlib import asynccontextmanager245from collections.abc import AsyncIterator246from fastapi import FastAPI247from fastapi.middleware.cors import CORSMiddleware248from app.config import settings249from app.api.router import api_router250from app.database import engine251252@asynccontextmanager253async def lifespan(app: FastAPI) -> AsyncIterator[None]:254 yield255 await engine.dispose()256257def 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 app263264app = create_app()265```266267## Testing268269Test at the API level (integration) and service level (unit).270271```python272# tests/conftest.py273import pytest274from httpx import ASGITransport, AsyncClient275from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine276from app.database import Base277from app.dependencies import get_session278from app.main import create_app279280@pytest.fixture281async 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 session288 async with engine.begin() as conn:289 await conn.run_sync(Base.metadata.drop_all)290 await engine.dispose()291292@pytest.fixture293async def client(session: AsyncSession):294 app = create_app()295 app.dependency_overrides[get_session] = lambda: session296 async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:297 yield c298```299300```python301# tests/test_posts.py302import pytest303pytestmark = pytest.mark.anyio304305async 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 == 201308 assert resp.json()["status"] == "draft"309310async def test_get_nonexistent_post_returns_404(client):311 resp = await client.get("/api/v1/posts/nonexistent-id")312 assert resp.status_code == 404313```314315## Conventions316317- **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"`.321322## Commands323324```bash325uv run fastapi dev # Dev server (hot reload)326uv run pytest # Run tests327uv run pytest --cov=app # Tests with coverage328uv run ruff check . && uv run ruff format . # Lint + format329uv run mypy src/ # Type check330uv run alembic upgrade head # Apply migrations331uv run alembic revision --autogenerate -m "description"332```333334## When Generating Code3353361. **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