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.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou 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) — nevertyping.List/Optional. On 3.14 annotations are lazily evaluated (PEP 649), so do NOT addfrom __future__ import annotationsand do NOT hide runtime-needed types behindif 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) andfastapi 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.xQueryAPI. - asyncpg 0.31 driver → URL
postgresql+asyncpg://. Alternative:psycopg3.2 (postgresql+psycopg://) which is also fully async. Neverpsycopg2in 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, committeduv.lock. Deps viauv add fastapi, run tools viauv run ruff check. Norequirements.txt, no barepip, 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 modelsPascalCase; constantsUPPER_SNAKE. Schemas are suffixed by intent:UserCreate,UserUpdate,UserRead. ORM model isUser; never reuse the same class for DB and API.Imports: absolute (
from app.services.user import ...), no relative..beyond the local package, noimport *. Let ruff's isort sort them.Config in
pyproject.toml:[tool.ruff]withtarget-version = "py314",line-length = 100; enable at leastselect = ["E","F","I","UP","B","ASYNC","SIM","TID","RUF"].[tool.mypy]withstrict = true(which already impliesdisallow_any_generics) andplugins = ["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 inawait anyio.to_thread.run_sync(...).MissingGreenletis 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 withselectinload()/joinedload()in the query, setexpire_on_commit=Falseon the sessionmaker, and never access lazy relationships afterawait 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 sessionThe
async withhandles rollback-on-exception and close. Do notcommit()insideget_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 withawait session.execute(stmt), then.scalars().first()/.scalar_one_or_none()/.all(). Useawait session.get(User, uid)for PK lookups. Neversession.query(...)(legacy 1.x).Models use typed mapped columns;
Mapped[int | None]expresses nullability. Timestamps via DBserver_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 withBase.metadata.create_allin production; that's for throwaway tests only.Pool tuning:
pool_pre_ping=Truealways; on serverless/pgbouncer usepoolclass=NullPool. Setpool_size/max_overflowexplicitly 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 v1orm_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 — notexclude— 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")returningself.Secrets use
SecretStr; read the value only where needed via.get_secret_value().
Dependency injection
Inject everything via
Dependsand type it withAnnotated. 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 userget_current_userdepends onOAuth2PasswordBearerfor token extraction, decodes with PyJWT, and raisesHTTPException(401, headers={"WWW-Authenticate": "Bearer"})on failure — it never returnsNone.Settings are injected, not imported at module top of routers:
SettingsDep = Annotated[Settings, Depends(get_settings)]withget_settings@lru_cached. This keeps tests overridable viaapp.dependency_overrides.Dependencies needing cleanup use
yield(likeget_db). Authorization guards that only gate access go in the router/endpointdependencies=[...]list (their return value is unused).
Routers, responses, status codes
- One
APIRouterper resource withprefixandtags; mount inmain.py. Give operations explicitresponse_model(or a precise return annotation) andstatus_code. - Use the
statusconstants, never magic ints:status_code=status.HTTP_201_CREATEDfor create,HTTP_204_NO_CONTENTfor delete (returnNone). - 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=Trueonly when the omission is intentional API design, not to paper over a wrong schema. - Pagination/filtering come from validated
Queryparams viaAnnotated: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 indb/session.py;lifespandisposes it on shutdown, and any real startup work (connectivity check, cache warmup) goes beforeyield:@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 = 30Load once via
@lru_cache. Never reados.environscattered 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'sASGITransport(no live server, noTestClientfor 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. Overrideget_settingsfor test config.Test the contract: status code,
response_modelshape, 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.pyper 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 acceptnoneor a client-suppliedalg), always set and verifyexp, and validateaud/issif 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; neverallow_origins=["*"]together withallow_credentials=True. - Add
TrustedHostMiddleware; enforce HTTPS at the proxy. Rate-limit at the edge (reverse proxy) or withslowapi. - Never leak internals: no ORM objects returned raw, no stack traces or driver errors in responses (
detailis 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
asyncservice/repository function that takesAsyncSession. - Annotate every function param and return; let mypy verify it. Prefer
Annotated[...]for allDepends/Query/Path/Body. - Eager-load relationships you will serialize (
selectinloadfor collections,joinedloadfor many-to-one). - Commit once per request at the service boundary; let
get_db's context manager handle rollback and close. - Use
TaskGroup/asyncio.gatherfor independent concurrent I/O within a request. - Return domain objects and let FastAPI serialize via
response_modelwithfrom_attributes=True. - Run
uv run ruff check --fix && uv run ruff format && uv run mypy src && uv run pytestbefore declaring done.
Avoid
@app.on_event("startup"/"shutdown")→ uselifespan.- Sync SQLAlchemy in async endpoints (
session.query(...), syncSession, psycopg2) → asyncselect()+AsyncSession+ asyncpg. This is what producesMissingGreenlet. - Pydantic v1 idioms (
class Config,@validator,.dict(),orm_mode) → v2ConfigDict,@field_validator,.model_dump(),from_attributes=True. - One model class shared for DB + request + response → separate
User(ORM) andUserCreate/UserRead(schemas). passlib/python-jose→pwdlib(Argon2) /PyJWT.typing.List/Optional/Dictandfrom __future__ import annotationson 3.14 → builtin generics and native lazy annotations.except:orexcept 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.txtin a uv project →uv add/uv syncagainst 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.