Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Framework · Python 3.14 · FastAPI 0.139 · Pydantic 2.13 · SQLAlchemy 2.0

Python + FastAPI

Async APIs with Pydantic v2, SQLAlchemy 2.0 and disciplined layering.

pythonfastapipydanticasyncsqlalchemy

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You are writing an async FastAPI service on modern Python. "Good" here means fully async end-to-end (no sync DB call in an async path), strict typing that passes mypy --strict, Pydantic v2 request/response schemas that never leak internal fields, and endpoints thin enough that the logic lives in typed service/repository functions. Match the versions and idioms below exactly; treat anything older as a bug to fix.

Stack

  • Python 3.14 (3.14.6). Baseline requires-python = ">=3.14". Use native generics (list[str], dict[str, int], X | None) — never typing.List/Optional. On 3.14 annotations are lazily evaluated (PEP 649), so do NOT add from __future__ import annotations and do NOT hide runtime-needed types behind if TYPE_CHECKING: (see below).
  • FastAPI 0.139 — needs 0.128.1+ for PEP 649 / Python 3.14 annotation support. Run with fastapi-cli: fastapi dev (reload) and fastapi run (prod).
  • Pydantic 2.13 + pydantic-settings 2.14.
  • SQLAlchemy 2.0.51 async ORM (Mapped / mapped_column / DeclarativeBase). Do not use 2.1 betas or the legacy 1.x Query API.
  • asyncpg 0.31 driver → URL postgresql+asyncpg://. Alternative: psycopg 3.2 (postgresql+psycopg://) which is also fully async. Never psycopg2 in an async engine.
  • Alembic 1.18 for migrations (async template).
  • uvicorn 0.50 ASGI server (behind the CLI).
  • pwdlib 0.3 (Argon2) for password hashing — not passlib. PyJWT 2.13 for JWT — not python-jose (unmaintained, CVE-prone).
  • uv 0.11 for env + deps + lockfile. ruff 0.15 (lint + format). mypy 2.1 (--strict). pytest 8 + pytest-asyncio 1.4 + httpx 0.28 (ASGITransport) for tests.

Project conventions

  • uv-managed project: single pyproject.toml, committed uv.lock. Deps via uv add fastapi, run tools via uv run ruff check. No requirements.txt, no bare pip, no Poetry.

  • src/ layout, package per bounded context, not per file-type-that-happens-to-share-a-name:

    src/app/
      main.py            # create_app(), lifespan, router mounting
      core/config.py     # Settings (pydantic-settings)
      core/security.py   # hashing, JWT
      db/session.py      # engine, async_sessionmaker, get_db dependency
      db/base.py         # DeclarativeBase
      models/            # SQLAlchemy ORM models
      schemas/           # Pydantic request/response models
      api/deps.py        # shared Depends (auth, pagination)
      api/routers/       # APIRouter per resource
      services/          # business logic, async, framework-agnostic
      repositories/      # DB access, takes AsyncSession
    tests/
    
  • Naming: modules/functions snake_case; classes/Pydantic/ORM models PascalCase; constants UPPER_SNAKE. Schemas are suffixed by intent: UserCreate, UserUpdate, UserRead. ORM model is User; never reuse the same class for DB and API.

  • Imports: absolute (from app.services.user import ...), no relative .. beyond the local package, no import *. Let ruff's isort sort them.

  • Config in pyproject.toml: [tool.ruff] with target-version = "py314", line-length = 100; enable at least select = ["E","F","I","UP","B","ASYNC","SIM","TID","RUF"]. [tool.mypy] with strict = true (which already implies disallow_any_generics) and plugins = ["pydantic.mypy"]. Ruff format is the formatter (Black-compatible); do not also run Black.

Async endpoints + async SQLAlchemy 2.0

  • Every endpoint that touches the DB is async def. Never call a sync/blocking function (sync driver, requests, time.sleep, heavy CPU) directly in an async path — it blocks the event loop. Wrap unavoidable blocking calls in await anyio.to_thread.run_sync(...).

  • MissingGreenlet is the signature bug of this stack: it means lazy I/O happened outside SQLAlchemy's async context — a relationship/attribute was accessed after the object left the session, or a sync operation ran on an async engine. Fixes: eager-load with selectinload() / joinedload() in the query, set expire_on_commit=False on the sessionmaker, and never access lazy relationships after await session.commit().

  • Engine + session live in db/session.py:

    engine = create_async_engine(settings.database_url, pool_pre_ping=True)
    AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False, autoflush=False)
    
    async def get_db() -> AsyncIterator[AsyncSession]:
        async with AsyncSessionLocal() as session:
            yield session
    

    The async with handles rollback-on-exception and close. Do not commit() inside get_db; commit in the service/repository at the unit-of-work boundary.

  • 2.0 query style only: build with select(User).where(User.id == uid), execute with await session.execute(stmt), then .scalars().first() / .scalar_one_or_none() / .all(). Use await session.get(User, uid) for PK lookups. Never session.query(...) (legacy 1.x).

  • Models use typed mapped columns; Mapped[int | None] expresses nullability. Timestamps via DB server_default:

    class User(Base):
        __tablename__ = "users"
        id: Mapped[int] = mapped_column(primary_key=True)
        email: Mapped[str] = mapped_column(unique=True, index=True)
        hashed_password: Mapped[str] = mapped_column()
        created_at: Mapped[datetime] = mapped_column(server_default=func.now())
    
  • Migrations via Alembic async (alembic init -t async), autogenerate reviewed by hand. Never create tables with Base.metadata.create_all in production; that's for throwaway tests only.

  • Pool tuning: pool_pre_ping=True always; on serverless/pgbouncer use poolclass=NullPool. Set pool_size/max_overflow explicitly for known concurrency.

Pydantic v2 schemas

  • Use v2 API: model_config = ConfigDict(...), @field_validator, @model_validator(mode="after"), .model_dump(), .model_validate(). Never v1 spellings (class Config, @validator, .dict(), .parse_obj(), orm_mode).

  • Read schemas that load from ORM objects set from_attributes=True (replaces v1 orm_mode):

    class UserRead(BaseModel):
        model_config = ConfigDict(from_attributes=True)
        id: int
        email: EmailStr
    
    class UserCreate(BaseModel):
        email: EmailStr
        password: str = Field(min_length=12, max_length=128)
    
  • Separate request and response models per operation. The response model must not contain hashed_password, internal FK ids you don't expose, or audit columns you want hidden. Omission — not exclude — is the safe default, because a field that isn't on the schema cannot leak.

  • Validate at the edge with real constraints: Field(gt=0), EmailStr, Annotated[str, StringConstraints(strip_whitespace=True, max_length=200)], HttpUrl. Put cross-field checks in @model_validator(mode="after") returning self.

  • Secrets use SecretStr; read the value only where needed via .get_secret_value().

Dependency injection

  • Inject everything via Depends and type it with Annotated. Define reusable aliases so signatures stay short:

    SessionDep = Annotated[AsyncSession, Depends(get_db)]
    CurrentUser = Annotated[User, Depends(get_current_user)]
    
    @router.get("/me", response_model=UserRead)
    async def read_me(user: CurrentUser) -> User:
        return user
    
  • get_current_user depends on OAuth2PasswordBearer for token extraction, decodes with PyJWT, and raises HTTPException(401, headers={"WWW-Authenticate": "Bearer"}) on failure — it never returns None.

  • Settings are injected, not imported at module top of routers: SettingsDep = Annotated[Settings, Depends(get_settings)] with get_settings @lru_cached. This keeps tests overridable via app.dependency_overrides.

  • Dependencies needing cleanup use yield (like get_db). Authorization guards that only gate access go in the router/endpoint dependencies=[...] list (their return value is unused).

Routers, responses, status codes

  • One APIRouter per resource with prefix and tags; mount in main.py. Give operations explicit response_model (or a precise return annotation) and status_code.
  • Use the status constants, never magic ints: status_code=status.HTTP_201_CREATED for create, HTTP_204_NO_CONTENT for delete (return None).
  • Errors are raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") — raise, don't return error dicts. Add app-wide handlers with @app.exception_handler(...) for domain exceptions so services stay free of HTTP concerns.
  • Set response_model_exclude_none=True only when the omission is intentional API design, not to paper over a wrong schema.
  • Pagination/filtering come from validated Query params via Annotated: q: Annotated[str | None, Query(max_length=50)] = None.

App lifecycle & settings

  • Use lifespan, never @app.on_event (deprecated). The engine/pool is created once at import in db/session.py; lifespan disposes it on shutdown, and any real startup work (connectivity check, cache warmup) goes before yield:

    @asynccontextmanager
    async def lifespan(app: FastAPI) -> AsyncIterator[None]:
        # startup work here (before yield)
        yield
        await engine.dispose()
    
    app = FastAPI(lifespan=lifespan)
    
  • Settings via pydantic-settings BaseSettings:

    class Settings(BaseSettings):
        model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_", extra="ignore")
        database_url: str
        jwt_secret: SecretStr
        access_token_ttl_minutes: int = 30
    

    Load once via @lru_cache. Never read os.environ scattered through the code; never hardcode secrets or commit .env.

Testing

  • pytest 8 + pytest-asyncio 1.4, mode asyncio_mode = "auto" in config. Test async endpoints with httpx's ASGITransport (no live server, no TestClient for async):

    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        r = await client.post("/users", json={...})
    
  • Use a real Postgres (Docker/testcontainers), not SQLite — async drivers, JSONB, and constraints diverge. Run each test in a transaction rolled back at teardown, or a fresh schema per session.

  • Override deps, don't monkeypatch internals: app.dependency_overrides[get_db] = _test_db. Override get_settings for test config.

  • Test the contract: status code, response_model shape, validation 422s, authz 401/403, and the not-found path — not just the happy path. Assert that sensitive fields are absent from responses.

  • Factories over fixtures-of-dicts for model data; keep one conftest.py per test package for shared fixtures.

Security

  • Hash passwords with pwdlib Argon2 (PasswordHash.recommended().hash() / .verify()). Never store plaintext, never MD5/SHA-1/unsalted SHA-256, never passlib.
  • JWT with PyJWT: sign with a strong secret/asymmetric key, pin the algorithm on decode (algorithms=["HS256"] — never accept none or a client-supplied alg), always set and verify exp, and validate aud/iss if you issue them. Keep access tokens short-lived; use rotating refresh tokens for longevity.
  • SQL injection: only ever pass values through bound parameters — SQLAlchemy expressions or text("... :id") with params. Never f-string / %-format user input into SQL.
  • CORS: list explicit origins in CORSMiddleware; never allow_origins=["*"] together with allow_credentials=True.
  • Add TrustedHostMiddleware; enforce HTTPS at the proxy. Rate-limit at the edge (reverse proxy) or with slowapi.
  • Never leak internals: no ORM objects returned raw, no stack traces or driver errors in responses (detail is a safe message), no secrets in logs. Validate/limit upload and body sizes.
  • Enforce authorization in a dependency, not by trusting a client-sent role/user id.

Do

  • Keep endpoints thin: parse/authorize in the endpoint, delegate to a typed async service/repository function that takes AsyncSession.
  • Annotate every function param and return; let mypy verify it. Prefer Annotated[...] for all Depends/Query/Path/Body.
  • Eager-load relationships you will serialize (selectinload for collections, joinedload for many-to-one).
  • Commit once per request at the service boundary; let get_db's context manager handle rollback and close.
  • Use TaskGroup / asyncio.gather for independent concurrent I/O within a request.
  • Return domain objects and let FastAPI serialize via response_model with from_attributes=True.
  • Run uv run ruff check --fix && uv run ruff format && uv run mypy src && uv run pytest before declaring done.

Avoid

  • @app.on_event("startup"/"shutdown") → use lifespan.
  • Sync SQLAlchemy in async endpoints (session.query(...), sync Session, psycopg2) → async select() + AsyncSession + asyncpg. This is what produces MissingGreenlet.
  • Pydantic v1 idioms (class Config, @validator, .dict(), orm_mode) → v2 ConfigDict, @field_validator, .model_dump(), from_attributes=True.
  • One model class shared for DB + request + response → separate User (ORM) and UserCreate/UserRead (schemas).
  • passlib / python-josepwdlib (Argon2) / PyJWT.
  • typing.List/Optional/Dict and from __future__ import annotations on 3.14 → builtin generics and native lazy annotations.
  • except: or except Exception: pass → catch the specific exception, or let it bubble to an exception handler. Never swallow silently.
  • Returning error dicts with 200 → raise HTTPException(...) with the correct status.
  • Blocking calls in async paths (requests, time.sleep, sync file/crypto) → async client / anyio.to_thread.run_sync.
  • Hiding a type used by FastAPI at runtime behind if TYPE_CHECKING: → import it normally; FastAPI needs it to build the schema/DI graph.
  • pip install / requirements.txt in a uv project → uv add / uv sync against the lockfile.

When you code

  • Make the smallest diff that solves the task; touch unrelated files only when asked. Match the file's existing patterns and layering (endpoint → service → repository).
  • After any change, run ruff (check + format), mypy src, and the relevant tests; report failures with the fix rather than leaving them.
  • When you add or change an endpoint, update/add its test (happy path + one validation/error path) and confirm the response schema hides sensitive fields.
  • When you change a model, generate and hand-review an Alembic migration — never edit the DB out of band.
  • Ask before: introducing a new dependency, changing the auth/security model, altering the public API shape (routes, status codes, response schemas), or adding a migration that drops/rewrites columns. State the tradeoff in one line and propose the default.
  • If a requirement forces a deprecated pattern (sync DB, shared model, wildcard CORS), flag it and implement the modern equivalent unless explicitly overruled.

Drop it in your repo

Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Python 3.14 · FastAPI 0.139 · Pydantic 2.13 · SQLAlchemy 2.0.

Back to top ↑