/mir-backend-python-fastapi · Make It Right (FastAPI)
Bottom tier of the chain: mir-backend (generic gates) → mir-backend-python (CPython runtime model) → this (FastAPI/SQLAlchemy library mechanics). Run the gates first; load the Python runtime tier for the concurrency/process model; reach for this at Gate 5 (design mechanics), Gate 6 (implementation), and Gate 7 review. Runtime-level concerns (GIL, async-vs-sync, blocking the event loop, fork-safe pools, cold start) live in mir-backend-python — not here.
Stack assumed: FastAPI · SQLAlchemy 2.0 async (AsyncSession, asyncpg) · PostgreSQL · Alembic · Redis. If the project uses sync SQLAlchemy or a different DB, note the divergence before applying these.
The FastAPI footguns AI walks into most
These are the stack-specific cousins of the failure-mode catalog. Each is something async-FastAPI code gets wrong even when the logic is right.
1. Async session lifecycle — the #1 source of mystery bugs
- One
AsyncSession per request, via a Depends dependency. Never a module-level/global session — it's not concurrency-safe and you'll get cross-request data bleed or InterfaceError.
- Never share one session across
asyncio.gather tasks. A session is a single connection's unit of work; concurrent use corrupts it. Give each task its own session from the factory.
- Commit/rollback boundary belongs in the dependency or service, explicitly — don't rely on autocommit.
expire_on_commit=False if you return ORM objects after commit, or you'll trigger lazy loads on detached instances.
2. The async N+1 — worse than sync, because lazy loads raise
- In async SQLAlchemy, accessing an unloaded relationship outside a session raises
MissingGreenlet / DetachedInstanceError — it doesn't silently query. Eager-load with selectinload() / joinedload() in the query. AI routinely writes order.items in a loop and ships an N+1 (or a crash).
3. Sync driver in the async path (the FastAPI face of a runtime rule)
- The general "don't block the event loop" rule lives in
mir-backend-python (runtime tier). Here, the FastAPI/SQLAlchemy specifics: use the async driver asyncpg (not psycopg2) with create_async_engine; for unavoidable blocking work use Starlette's run_in_threadpool (or define the route as plain def so Starlette runs it in a threadpool automatically). Don't put a sync Session behind an async def route.
4. Pydantic v2 is the boundary, not the security layer
- Validation ≠ authorization. A valid body can still be a request to mutate someone else's data.
- Mass assignment: never build an ORM object from
model_dump() blindly — use a separate Create/Update schema that excludes id, is_admin, tenant_id, role. Use response_model to avoid leaking fields (password hashes, internal flags) on the way out.
model_config with extra="forbid" on input schemas to reject unexpected fields.
5. Depends() auth — get the chain right
- Authentication (
get_current_user) and authorization (does this user own this object?) are different dependencies. AI implements the first and forgets the second → IDOR. Object-level checks go in the path that loads the object, not just the route guard.
- Dependencies are cached per-request by default — fine, but don't put side effects in them expecting them to run twice.
6. BackgroundTasks is not a job queue
BackgroundTasks runs in the same process after the response; if the process dies, the work is lost — no retries, no durability. Fine for best-effort (a fire-and-forget log). Not fine for "send the receipt email" or anything that must happen. For durable/at-least-once work use a real queue (Celery/RQ/arq + Redis) with idempotent handlers. This is where the at-least-once/idempotency rule from the core skill lands in FastAPI.
7. Redis for idempotency & locking — the right primitives
- Idempotency key:
SET key result NX EX <ttl> — NX makes "first writer wins" atomic. Store the result so a retry returns the same response, not a second side effect.
- Distributed lock:
SET lock token NX EX <ttl> with a token you check before releasing (don't delete someone else's lock). Remember Redis locks are best-effort, not a correctness guarantee under failover — for money, prefer a DB row lock and use Redis only to reduce contention.
- Cache stampede: on a cold popular key, N requests miss simultaneously → N DB hits. Use a short lock or
SET NX sentinel so one request rebuilds while others wait/serve stale.
8. Transactions in async SQLAlchemy
async with session.begin(): for an explicit tx block. Irreversible external effects (charge, email) go after commit, guarded by the idempotency key — never inside the tx, because a rollback can't un-send an email.
- Row lock for contended updates:
select(...).with_for_update() inside the tx.
- Conditional UPDATE for state transitions:
update(Order).where(Order.id==id, Order.status=="PENDING").values(status="PAID") and check rowcount — this makes concurrent transitions safe without a read-modify-write race.
Alembic migration safety
See references/alembic-migration-safety.md for the full expand/contract patterns. Headline rule: AI writes migrations as if the table is empty. It isn't. The migration-reviewer agent enforces this at Gate 7.
How this slots into the core pipeline
- Gate 5 (Design): when you state transaction boundaries and the idempotency mechanism, use the async-SQLAlchemy patterns above (session scope,
with_for_update, conditional UPDATE, Redis key shape).
- Gate 6 (Implementation): code against
references/fastapi-gotchas.md alongside the core codegen checklist.
- Gate 7 (Review): the reliability-reviewer should additionally check items 1–8 here; the migration-reviewer reads
references/alembic-migration-safety.md.
References
references/fastapi-gotchas.md — expanded code-level examples of the 8 footguns above (right vs wrong).
references/alembic-migration-safety.md — expand/contract, batched backfill, CONCURRENTLY, NOT VALID then VALIDATE, rolling-deploy compatibility.
Edit boundary (what belongs here vs. the core)
This module holds ONLY one library's mechanics — FastAPI · Async SQLAlchemy 2.0 · Postgres · Alembic · Redis. Apply the 3-tier placement test before adding anything:
- True for Go/Node/Java too (idempotency, invariants, gates, risk register, observability principle)? → generic core (
mir-backend).
- True for every Python framework on CPython (GIL, async-vs-sync, blocking the loop, fork-safe pools, cold start, asyncio task hygiene)? → runtime tier (
mir-backend-python).
- A mechanical footgun of this library (async session scope,
selectinload N+1, with_for_update, Pydantic boundaries, Alembic-on-populated-table, Redis SET NX)? → here.
- A different framework on Python (Django, Flask) → new
mir-backend-python-<framework> module. A different runtime → its own tier. Never widen this one.
Full layered edit map: see mir-backend/SKILL.md → "Where these instructions live".
Source: anantbhandarkar/make-it-right — distributed by TomeVault.
1---2name: anantbhandarkar-make-it-right-mir-backend-python-fastapi3description: Anantbhandarkar Make It Right Mir Backend Python Fastapi4---56# /mir-backend-python-fastapi · Make It Right (FastAPI)78Bottom tier of the chain: `mir-backend` (generic gates) → `mir-backend-python` (CPython runtime model) → **this** (FastAPI/SQLAlchemy library mechanics). Run the gates first; load the Python runtime tier for the concurrency/process model; reach for *this* at Gate 5 (design mechanics), Gate 6 (implementation), and Gate 7 review. **Runtime-level concerns (GIL, async-vs-sync, blocking the event loop, fork-safe pools, cold start) live in `mir-backend-python` — not here.**910**Stack assumed:** FastAPI · SQLAlchemy 2.0 async (`AsyncSession`, `asyncpg`) · PostgreSQL · Alembic · Redis. If the project uses sync SQLAlchemy or a different DB, note the divergence before applying these.1112## The FastAPI footguns AI walks into most1314These are the stack-specific cousins of the failure-mode catalog. Each is something async-FastAPI code gets wrong even when the *logic* is right.1516### 1. Async session lifecycle — the #1 source of mystery bugs17- **One `AsyncSession` per request, via a `Depends` dependency.** Never a module-level/global session — it's not concurrency-safe and you'll get cross-request data bleed or `InterfaceError`.18- **Never share one session across `asyncio.gather` tasks.** A session is a single connection's unit of work; concurrent use corrupts it. Give each task its own session from the factory.19- **Commit/rollback boundary belongs in the dependency or service, explicitly** — don't rely on autocommit. `expire_on_commit=False` if you return ORM objects after commit, or you'll trigger lazy loads on detached instances.2021### 2. The async N+1 — worse than sync, because lazy loads *raise*22- In async SQLAlchemy, accessing an unloaded relationship outside a session raises `MissingGreenlet` / `DetachedInstanceError` — it doesn't silently query. Eager-load with `selectinload()` / `joinedload()` in the query. AI routinely writes `order.items` in a loop and ships an N+1 (or a crash).2324### 3. Sync driver in the async path (the FastAPI face of a runtime rule)25- The general "don't block the event loop" rule lives in **`mir-backend-python`** (runtime tier). Here, the FastAPI/SQLAlchemy specifics: use the **async** driver `asyncpg` (not `psycopg2`) with `create_async_engine`; for unavoidable blocking work use Starlette's `run_in_threadpool` (or define the route as plain `def` so Starlette runs it in a threadpool automatically). Don't put a sync `Session` behind an `async def` route.2627### 4. Pydantic v2 is the boundary, not the security layer28- Validation ≠ authorization. A valid body can still be a request to mutate someone else's data.29- **Mass assignment:** never build an ORM object from `model_dump()` blindly — use a separate `Create`/`Update` schema that *excludes* `id`, `is_admin`, `tenant_id`, `role`. Use `response_model` to avoid leaking fields (password hashes, internal flags) on the way out.30- `model_config` with `extra="forbid"` on input schemas to reject unexpected fields.3132### 5. `Depends()` auth — get the chain right33- Authentication (`get_current_user`) and **authorization** (does this user own this object?) are different dependencies. AI implements the first and forgets the second → IDOR. Object-level checks go in the path that loads the object, not just the route guard.34- Dependencies are cached per-request by default — fine, but don't put side effects in them expecting them to run twice.3536### 6. `BackgroundTasks` is not a job queue37- `BackgroundTasks` runs in the same process *after* the response; if the process dies, the work is lost — no retries, no durability. Fine for best-effort (a fire-and-forget log). **Not fine** for "send the receipt email" or anything that must happen. For durable/at-least-once work use a real queue (Celery/RQ/arq + Redis) with idempotent handlers. This is where the at-least-once/idempotency rule from the core skill lands in FastAPI.3839### 7. Redis for idempotency & locking — the right primitives40- **Idempotency key:** `SET key result NX EX <ttl>` — `NX` makes "first writer wins" atomic. Store the result so a retry returns the same response, not a second side effect.41- **Distributed lock:** `SET lock token NX EX <ttl>` with a token you check before releasing (don't delete someone else's lock). Remember Redis locks are best-effort, not a correctness guarantee under failover — for money, prefer a DB row lock and use Redis only to reduce contention.42- **Cache stampede:** on a cold popular key, N requests miss simultaneously → N DB hits. Use a short lock or `SET NX` sentinel so one request rebuilds while others wait/serve stale.4344### 8. Transactions in async SQLAlchemy45- `async with session.begin():` for an explicit tx block. Irreversible external effects (charge, email) go *after* commit, guarded by the idempotency key — never inside the tx, because a rollback can't un-send an email.46- Row lock for contended updates: `select(...).with_for_update()` inside the tx.47- Conditional UPDATE for state transitions: `update(Order).where(Order.id==id, Order.status=="PENDING").values(status="PAID")` and check `rowcount` — this makes concurrent transitions safe without a read-modify-write race.4849## Alembic migration safety5051See `references/alembic-migration-safety.md` for the full expand/contract patterns. Headline rule: **AI writes migrations as if the table is empty.** It isn't. The migration-reviewer agent enforces this at Gate 7.5253## How this slots into the core pipeline5455- **Gate 5 (Design):** when you state transaction boundaries and the idempotency mechanism, use the async-SQLAlchemy patterns above (session scope, `with_for_update`, conditional UPDATE, Redis key shape).56- **Gate 6 (Implementation):** code against `references/fastapi-gotchas.md` alongside the core codegen checklist.57- **Gate 7 (Review):** the reliability-reviewer should additionally check items 1–8 here; the migration-reviewer reads `references/alembic-migration-safety.md`.5859## References6061- `references/fastapi-gotchas.md` — expanded code-level examples of the 8 footguns above (right vs wrong).62- `references/alembic-migration-safety.md` — expand/contract, batched backfill, `CONCURRENTLY`, `NOT VALID` then `VALIDATE`, rolling-deploy compatibility.6364## Edit boundary (what belongs here vs. the core)6566**This module holds ONLY one library's mechanics — FastAPI · Async SQLAlchemy 2.0 · Postgres · Alembic · Redis.** Apply the 3-tier placement test before adding anything:6768- True for Go/Node/Java too (idempotency, invariants, gates, risk register, observability principle)? → **generic core** (`mir-backend`).69- True for every Python framework on CPython (GIL, async-vs-sync, blocking the loop, fork-safe pools, cold start, asyncio task hygiene)? → **runtime tier** (`mir-backend-python`).70- A mechanical footgun of *this library* (async session scope, `selectinload` N+1, `with_for_update`, Pydantic boundaries, Alembic-on-populated-table, Redis `SET NX`)? → **here**.71- A *different* framework on Python (Django, Flask) → new `mir-backend-python-<framework>` module. A *different* runtime → its own tier. Never widen this one.7273Full layered edit map: see `mir-backend/SKILL.md` → "Where these instructions live".7475---76> Source: [anantbhandarkar/make-it-right](https://github.com/anantbhandarkar/make-it-right) — distributed by [TomeVault](https://tomevault.io).77<!-- tomevault:4.0:skill_md:2026-06-15 -->