FastAPI rules for AI coding agents
FastAPI makes it easy to write an async def route, and just as easy for an agent to put a blocking call inside it. One sync database query or requests.get in an async route stalls the event loop for every request on that worker. The FastAPI rules are written for services on Pydantic v2 with async SQLAlchemy, and they are meant to sit on top of the general Python rules.
Before anything else they make the agent check the pydantic version in the lock file, because v1 idioms like class Config, .dict() and @validator must not be mixed into a v2 project. The rest covers dependencies declared with Annotated, the lifespan handler instead of @app.on_event, authorization on the resource rather than just the id, and a hard maximum on every list route. The prompt card below is the one included in full in the free sample PDF.
Folder: rules/fastapi/ · fastapi.mdc, CLAUDE.md, AGENTS.md
What the FastAPI rules tell your agent
- Pydantic v2 conventionsUse
model_config = ConfigDict(...),model_dump(),model_validate(),@field_validatorand@model_validator. The v1 names are deprecated. - DependenciesUse
Dependsfor sessions, the current user, settings and pagination. Declare them withAnnotated:SessionDep = Annotated[AsyncSession, Depends(get_session)]. - Async correctness
async defroutes run on the event loop. Any blocking call inside one (requests, sync SQLAlchemy,time.sleep, heavy CPU) stalls every request. - Application structureUse the
lifespancontext manager for startup and shutdown;@app.on_eventis deprecated. - Errors and responsesRaise
HTTPException(status_code=404, detail="user not found")for client errors. For domain exceptions, register@app.exception_handler(DomainError)rather than catching in every route.
First rule of each section in fastapi.mdc, quoted as shipped.
Mistakes these rules are written to prevent
- Mutating a shared module-level list or dict as a cache across requests without a lock or TTL.
- Defining a route
/users/meafter/users/{user_id}: the path parameter route matches first. Declare fixed paths first. - Declaring a body parameter as a plain
dict. You lose validation and documentation; use a model.
Source: “Pitfalls to avoid”.
Checks the agent runs before it says done
Example commands from the FastAPI file (swap in your repo's own):
- Lint:
ruff check . - Types:
mypy app - Tests:
pytest -q - Migrations:
alembic upgrade head(local database only)
Run Ruff, the type checker and pytest. Open /docs or fetch /openapi.json once if you changed schemas, to confirm the contract you meant to publish.
Before and after: FastAPI: blocking calls inside async routes
Prompt card 11 · Stack: FastAPI, Python 3.10+ · Type: Performance and correctness review · Best model: a frontier reasoning model
Purpose
Find synchronous I/O and CPU work inside async def routes and dependencies. One blocking call stalls the event loop, so every other request on that worker waits too. Fix each by switching to an async client, declaring the route with plain def, or offloading to a thread or job.
When to use it
- Latency is fine with one user and collapses under modest concurrency.
- The codebase mixes
requests, sync SQLAlchemy sessions or boto3 withasync defroutes.
Inputs it needs
- The routers, dependencies and services in the hot path.
- The database driver and session type, and the HTTP client in use.
The prompt
Find blocking work on the event loop in this FastAPI app.
Database stack: {{db_stack}}. HTTP client(s): {{http_clients}}.
{{code}}
For each route and dependency, report:
- async or sync definition,
- every call inside it that blocks (sync HTTP clients, sync DB sessions, file I/O,
time.sleep, CPU-heavy loops, sync SDKs such as boto3),
- the recommended fix, in this order of preference:
1. use an async equivalent already in the project,
2. declare the route or dependency with plain `def` so FastAPI runs it in its threadpool,
3. wrap the single call with `await run_in_threadpool(...)` or `anyio.to_thread.run_sync`,
4. move it to a background job if the response does not need the result.
Do not convert the whole app to async in one go; fix the listed routes only.
Output a table, then the corrected code.
Worked example
Before
@router.get("/users/{user_id}/avatar")
async def avatar(user_id: int, db: Session = Depends(get_db)):
user = db.query(User).get(user_id)
img = requests.get(user.avatar_url).content
thumb = make_thumbnail(img, 128) # Pillow resize
return Response(thumb, media_type="image/png")
Expected AI output (abridged)
| GET /users/{id}/avatar | async | db.query (sync Session) blocks | requests.get blocks with no
| timeout | make_thumbnail is CPU work (about 20-80 ms per image) |
Fix chosen: keep async, use the project's shared httpx.AsyncClient, run the thumbnail in a
thread, and switch this route to the async session that other routes already use.
Also: Query.get() is legacy in SQLAlchemy 2.0; use session.get(User, user_id).
Missing 404 when the user does not exist.
After
@router.get("/users/{user_id}/avatar")
async def avatar(user_id: int, session: SessionDep, http: HttpClientDep) -> Response:
user = await session.get(User, user_id)
if user is None or not user.avatar_url:
raise HTTPException(status_code=404, detail="avatar not found")
resp = await http.get(user.avatar_url, timeout=5.0)
resp.raise_for_status()
thumb = await run_in_threadpool(make_thumbnail, resp.content, 128)
return Response(thumb, media_type="image/png", headers={"Cache-Control": "public, max-age=3600"})
Pitfalls
- Plain
defroutes run in a threadpool with a limited number of threads (40 by default in AnyIO). Many slow sync routes can exhaust it; that is a reason to go async for hot paths. - Fetching a user-supplied URL server-side is an SSRF risk. Restrict avatar URLs to your storage domain.
- Adding a
Cache-Controlheader is a behavior change; drop it if a CDN already handles caching.
Shown in full, as in the free sample PDF.
More FastAPI prompt cards in the pack
- Card 12: FastAPI: response models and data leaks
Using these rules in Cursor, Claude Code and AGENTS.md tools
Cursor
Put _base.mdc and fastapi.mdc in .cursor/rules/. Globs: app/**/*.py, src/**/*.py, tests/**/*.py, alembic/**/*.py.
Claude Code
Root CLAUDE.md, base first. Path-scoped variant .claude/rules/fastapi.md for app/**/*.py, tests/**/*.py; PostToolUse hook: ruff format <file>.
AGENTS.md tools (Codex, Gemini CLI, Aider)
Base then stack in one AGENTS.md. Gemini CLI needs context.fileName; Aider needs --read AGENTS.md. Put this file in services/api/ or the repo root. Agents use the AGENTS.md nearest to the file being edited, so a nested copy overrides a root one for that subtree.