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

First rule of each section in fastapi.mdc, quoted as shipped.

Mistakes these rules are written to prevent

Source: “Pitfalls to avoid”.

Checks the agent runs before it says done

Example commands from the FastAPI file (swap in your repo's own):

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 with async def routes.

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 def routes 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-Control header 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

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.

Related stacks

← All stacks