FastAPI AI Patterns
Opinionated, production-calibrated guidance for FastAPI services — with the
focus on what makes AI/ML/LLM serving different from an ordinary CRUD API.
It is a knowledge skill: it teaches the decisions and the traps, then routes you
to a per-topic reference for depth.
Stock agents already write basic FastAPI well (routes, Pydantic models, simple
CRUD). This skill spends its tokens on the parts they get wrong by default:
the concurrency model, the lifespan/singleton lifecycle, the security boundaries,
and the discipline that turns a stochastic LLM into a typed component.
Inspired by FastAPI for AI Engineers: From First Endpoint to Production-Scale
AI Systems (AI Engineering Insider, 2026). All content here is re-expressed in
original wording; consult the book for its worked examples and case studies.
When to use
- Building a FastAPI service that serves ML models, embeddings, RAG, or wraps an LLM
- Reviewing or debugging a FastAPI app for production-readiness (latency, leaks, auth)
- Deciding
def vs async def for a handler, or diagnosing a "p99 exploded under load" incident
- Designing inference, streaming-chat, embedding, or document-Q&A endpoints
- Hardening an endpoint: response shape, authz, rate limiting, input/output guardrails
When NOT to use
- You want a full project skeleton generated → use
fastapi-ai-scaffold
- You are drilling interview questions on these topics → use
fastapi-ai-interview-prep
- Pure frontend/Next.js work, or a non-FastAPI Python web stack (Flask/Django) → different skills
Core mental model: the endpoint is a typed contract
A FastAPI endpoint signature is the API contract. Parameter annotations
declare what the request must contain; the return annotation or response_model
declares what the response will contain. The framework enforces both at runtime
and publishes them as OpenAPI. Everything else — validation, docs, the 422 error
body, SDK generation — is derived from that one declaration.
Two consequences worth internalizing:
- The annotation is the single source of truth. Don't duplicate validation
logic that a Pydantic field constraint already expresses.
- FastAPI is a thin layer over Starlette + Pydantic. When you debug, you are
often reading Starlette (routing, middleware,
BackgroundTasks) or Pydantic
(validation, coercion) source. Knowing which layer owns a behavior is the
difference between guessing and fixing.
The single most important decision: def vs async def
FastAPI runs async def handlers on one event loop; it dispatches plain
def handlers to a threadpool (~40 threads by default). Choose by the
workload, not by habit:
| Workload |
Correct handler |
Why |
Async-capable I/O (httpx, asyncpg, async redis) |
async def + await |
Loop interleaves thousands of in-flight waits |
Blocking-only library (requests, classic ORM, boto3) |
plain def |
Threadpool absorbs the block; loop stays free |
| Light CPU (< a few ms) |
either |
Negligible either way |
| Heavy CPU (model inference, parsing, crypto) |
plain def, or offload (to_thread/process pool/queue) |
Never run multi-ms CPU on the loop |
The deadliest FastAPI production bug: an async def handler that makes a
blocking call inside it (requests.get, time.sleep, a sync model
.predict(), a CPU-heavy parse). It passes every single-request test, then under
concurrency it freezes the entire loop — and p99 explodes for every endpoint
at once, including /health. See references/async-and-external.md.
Top gotchas (read before you write the handler)
These are the corrections an agent most often needs. They are environment facts
that defy reasonable assumptions, so they live here, not in a reference.
async def + blocking call = whole-process stall. One non-cooperating
coroutine starves every other request on the loop. If a call isn't truly
async, use plain def or offload it. Detect with a loop-lag watchdog.
- Load ML models once, in the
lifespan handler — never per request, never at
import time. Per-request loading re-reads weights on every call (and OOMs
under concurrency); import-time loading makes tests, linters, and CLI tools pay
the cost (or crash without a GPU). Lifespan loading runs once per worker before
traffic and integrates with readiness probes. With N workers you hold N copies
— size worker count by memory, or front a dedicated inference server.
- Every response needs a
response_model (or explicit return type). Returning
an ORM object directly serializes all its columns — that is how
hashed_password ends up in a JSON response. Separate request / storage /
response schemas; the response schema is a structural leak-prevention boundary.
- Object-level authorization must be enforced in the query, not by review.
BOLA (broken object-level authorization) tops the OWASP API list because
GET /orders/{id} that trusts the path id leaks other users' data. Filter by
owner in the data layer (WHERE owner_id = current_user.id), so an unauthorized
fetch returns 404 by construction — don't rely on a reviewer remembering.
- Echo
model_version (and embedding model version) in every AI response.
When quality regresses the first question is "which model produced this?", and
canary rollouts serve multiple versions at once. Vectors from different
embedding models are incomparable — a model upgrade means re-embedding the corpus.
- Treat LLM output as untrusted input: generate → validate with Pydantic →
retry with the error appended → fall back. This loop is the single most
useful LLM-engineering pattern: it converts a stochastic text generator into a
typed component with an explicit failure mode. One retry fixes most cases;
on the second failure raise a typed error to a defined fallback (human review,
cheaper deterministic path, or 502) — never pass malformed data downstream.
- In RAG, enforce access control in retrieval (SQL/filter), never in the
prompt. Never let the model see chunks the user can't access and "ask it" to
respect permissions — prompt injection defeats that. The authorization boundary
lives outside the model.
- Per-process singletons (
httpx.AsyncClient, DB engine, Redis client, model
handle) belong in lifespan, injected via a dependency — not constructed per
request. A per-request httpx client pays TCP+TLS handshakes every call and
leaks connections; a lifespan-scoped client reuses keepalive connections and
enforces global limits.
- Wrap streaming generators in
try/finally. Clients disconnect mid-stream
constantly; token metering, logging, and upstream stream cancellation must run
even then, or you keep paying for abandoned generations and lose usage data.
- Keep
/health trivially cheap; use a separate /ready for dependency
readiness. A health check that does real work (or shares the event loop with
heavy handlers) will fail under load and trigger restart storms. Liveness and
readiness are different questions and need different probes.
- Retry only idempotent operations on transient errors, with backoff + jitter
and a total deadline below your caller's timeout. Naive retries amplify load
exactly when an upstream is weakest (retry storm); synchronized retries are a
self-inflicted thundering herd. Add a circuit breaker for repeated failure.
- Validate JWTs with a pinned algorithm; hash passwords with bcrypt/argon2, not
SHA-256. Accepting the token's own
alg header enables the alg=none forgery;
fast hashes make password cracking cheap. See references/security.md.
How to use the references
Load the one reference that matches the decision in front of you — don't preload
them all. Each is self-contained with patterns, gotchas, and capacity/cost rules
restated in original wording.
references/api-design.md — Read when designing routes, status codes,
pagination/versioning, or Pydantic v2 request/response schemas (Ch 1–3 scope:
ASGI vs WSGI, REST semantics, idempotency, validation, leak prevention).
references/architecture-di.md — Read when structuring a project or wiring
Depends() (Ch 4: DI resolution + caching, yield deps for DB sessions,
router/service/repository layering, composition root, dependency_overrides).
references/database.md — Read when adding persistence (Ch 5: session-per-
request, N+1 + selectinload/joinedload, SQLModel vs SQLAlchemy+Pydantic,
Alembic, pool sizing, PgBouncer, flush vs commit).
references/security.md — Read when adding auth or hardening (Ch 6: password
hashing, JWT + revocation, API keys, CORS, rate limiting, OWASP API risks, BOLA).
references/testing.md — Read when writing tests (Ch 7: TestClient,
dependency_overrides, SQLite-in-memory tiers, authz matrix, time injection,
coverage honesty, 422-body assertions).
references/async-and-external.md — Read when doing concurrency or external
calls (Ch 8: event loop model, the full def/async table, httpx client +
retries + circuit breaker, BackgroundTasks vs Celery, webhooks, uploads,
streaming, WebSockets, backpressure).
references/ai-ml-serving.md — Read when serving models or LLMs (Ch 9:
model loading, batch + dynamic micro-batching, LLM gateway + SSE streaming,
embeddings + vector DB + RAG, guardrails, validation loop, versioning/monitoring/
HITL, LLM cost model).
references/deployment-observability.md — Read when deploying or operating
(Ch 10: Uvicorn/Gunicorn workers, Docker, K8s probes, logs/metrics/traces,
caching + stampede, load testing, graceful degradation, production checklist).
Authoritative sources (link, don't paraphrase from memory)
FastAPI's API surface and the surrounding ecosystem change between minor
versions. When unsure of exact syntax or current behavior, fetch the docs:
Gotchas
The top, cross-cutting gotchas are in the section above (they apply before you
even pick a topic). Topic-specific traps live in each reference's own ## Gotchas
section — for example, flush() vs commit() timing in database.md, the
alg=none attack in security.md, and SSE proxy-buffering in
ai-ml-serving.md. Load the reference for the area you're working in so you read
its gotchas before hitting the situation, not after.
1---2name: fastapi-ai-patterns3description: Production patterns and gotchas for building FastAPI services, especially AI/ML/LLM serving. Use when you build, review, or debug a FastAPI app: choosing def vs async def, loading models in lifespan, preventing event-loop blocking, setting response_model to stop data leaks, enforcing object-level authz in queries, wrapping LLMs with streaming + Pydantic validation loops, or designing RAG/embedding endpoints.4---56# FastAPI AI Patterns78Opinionated, production-calibrated guidance for FastAPI services — with the9focus on what makes **AI/ML/LLM serving** different from an ordinary CRUD API.10It is a knowledge skill: it teaches the decisions and the traps, then routes you11to a per-topic reference for depth.1213Stock agents already write basic FastAPI well (routes, Pydantic models, simple14CRUD). This skill spends its tokens on the parts they get **wrong by default**:15the concurrency model, the lifespan/singleton lifecycle, the security boundaries,16and the discipline that turns a stochastic LLM into a typed component.1718> Inspired by *FastAPI for AI Engineers: From First Endpoint to Production-Scale19> AI Systems* (AI Engineering Insider, 2026). All content here is re-expressed in20> original wording; consult the book for its worked examples and case studies.2122## When to use2324- Building a FastAPI service that serves ML models, embeddings, RAG, or wraps an LLM25- Reviewing or debugging a FastAPI app for production-readiness (latency, leaks, auth)26- Deciding `def` vs `async def` for a handler, or diagnosing a "p99 exploded under load" incident27- Designing inference, streaming-chat, embedding, or document-Q&A endpoints28- Hardening an endpoint: response shape, authz, rate limiting, input/output guardrails2930## When NOT to use3132- You want a full project skeleton generated → use `fastapi-ai-scaffold`33- You are drilling interview questions on these topics → use `fastapi-ai-interview-prep`34- Pure frontend/Next.js work, or a non-FastAPI Python web stack (Flask/Django) → different skills3536## Core mental model: the endpoint is a typed contract3738A FastAPI endpoint signature **is** the API contract. Parameter annotations39declare what the request must contain; the return annotation or `response_model`40declares what the response will contain. The framework enforces both at runtime41and publishes them as OpenAPI. Everything else — validation, docs, the 422 error42body, SDK generation — is derived from that one declaration.4344Two consequences worth internalizing:45461. **The annotation is the single source of truth.** Don't duplicate validation47 logic that a Pydantic field constraint already expresses.482. **FastAPI is a thin layer over Starlette + Pydantic.** When you debug, you are49 often reading Starlette (routing, middleware, `BackgroundTasks`) or Pydantic50 (validation, coercion) source. Knowing which layer owns a behavior is the51 difference between guessing and fixing.5253## The single most important decision: `def` vs `async def`5455FastAPI runs `async def` handlers on **one event loop**; it dispatches plain56`def` handlers to a **threadpool** (~40 threads by default). Choose by the57workload, not by habit:5859| Workload | Correct handler | Why |60|---|---|---|61| Async-capable I/O (`httpx`, `asyncpg`, async redis) | `async def` + `await` | Loop interleaves thousands of in-flight waits |62| Blocking-only library (`requests`, classic ORM, `boto3`) | plain `def` | Threadpool absorbs the block; loop stays free |63| Light CPU (< a few ms) | either | Negligible either way |64| Heavy CPU (model inference, parsing, crypto) | plain `def`, or offload (`to_thread`/process pool/queue) | Never run multi-ms CPU on the loop |6566The deadliest FastAPI production bug: an `async def` handler that makes a67**blocking** call inside it (`requests.get`, `time.sleep`, a sync model68`.predict()`, a CPU-heavy parse). It passes every single-request test, then under69concurrency it freezes the entire loop — and p99 explodes for **every** endpoint70at once, including `/health`. See `references/async-and-external.md`.7172## Top gotchas (read before you write the handler)7374These are the corrections an agent most often needs. They are environment facts75that defy reasonable assumptions, so they live here, not in a reference.7677- **`async def` + blocking call = whole-process stall.** One non-cooperating78 coroutine starves every other request on the loop. If a call isn't truly79 async, use plain `def` or offload it. Detect with a loop-lag watchdog.80- **Load ML models once, in the `lifespan` handler — never per request, never at81 import time.** Per-request loading re-reads weights on every call (and OOMs82 under concurrency); import-time loading makes tests, linters, and CLI tools pay83 the cost (or crash without a GPU). Lifespan loading runs once per worker before84 traffic and integrates with readiness probes. With N workers you hold N copies85 — size worker count by memory, or front a dedicated inference server.86- **Every response needs a `response_model` (or explicit return type).** Returning87 an ORM object directly serializes *all* its columns — that is how88 `hashed_password` ends up in a JSON response. Separate request / storage /89 response schemas; the response schema is a structural leak-prevention boundary.90- **Object-level authorization must be enforced in the query, not by review.**91 BOLA (broken object-level authorization) tops the OWASP API list because92 `GET /orders/{id}` that trusts the path id leaks other users' data. Filter by93 owner in the data layer (`WHERE owner_id = current_user.id`), so an unauthorized94 fetch returns 404 by construction — don't rely on a reviewer remembering.95- **Echo `model_version` (and embedding model version) in every AI response.**96 When quality regresses the first question is "which model produced this?", and97 canary rollouts serve multiple versions at once. Vectors from different98 embedding models are incomparable — a model upgrade means re-embedding the corpus.99- **Treat LLM output as untrusted input: generate → validate with Pydantic →100 retry with the error appended → fall back.** This loop is the single most101 useful LLM-engineering pattern: it converts a stochastic text generator into a102 typed component with an explicit failure mode. One retry fixes most cases;103 on the second failure raise a typed error to a defined fallback (human review,104 cheaper deterministic path, or 502) — never pass malformed data downstream.105- **In RAG, enforce access control in retrieval (SQL/filter), never in the106 prompt.** Never let the model see chunks the user can't access and "ask it" to107 respect permissions — prompt injection defeats that. The authorization boundary108 lives outside the model.109- **Per-process singletons (`httpx.AsyncClient`, DB engine, Redis client, model110 handle) belong in `lifespan`, injected via a dependency — not constructed per111 request.** A per-request `httpx` client pays TCP+TLS handshakes every call and112 leaks connections; a lifespan-scoped client reuses keepalive connections and113 enforces global limits.114- **Wrap streaming generators in `try/finally`.** Clients disconnect mid-stream115 constantly; token metering, logging, and upstream stream cancellation must run116 even then, or you keep paying for abandoned generations and lose usage data.117- **Keep `/health` trivially cheap; use a separate `/ready` for dependency118 readiness.** A health check that does real work (or shares the event loop with119 heavy handlers) will fail under load and trigger restart storms. Liveness and120 readiness are different questions and need different probes.121- **Retry only idempotent operations on transient errors, with backoff + jitter122 and a total deadline below your caller's timeout.** Naive retries amplify load123 exactly when an upstream is weakest (retry storm); synchronized retries are a124 self-inflicted thundering herd. Add a circuit breaker for repeated failure.125- **Validate JWTs with a pinned algorithm; hash passwords with bcrypt/argon2, not126 SHA-256.** Accepting the token's own `alg` header enables the `alg=none` forgery;127 fast hashes make password cracking cheap. See `references/security.md`.128129## How to use the references130131Load the one reference that matches the decision in front of you — don't preload132them all. Each is self-contained with patterns, gotchas, and capacity/cost rules133restated in original wording.134135- **`references/api-design.md`** — Read when designing routes, status codes,136 pagination/versioning, or Pydantic v2 request/response schemas (Ch 1–3 scope:137 ASGI vs WSGI, REST semantics, idempotency, validation, leak prevention).138- **`references/architecture-di.md`** — Read when structuring a project or wiring139 `Depends()` (Ch 4: DI resolution + caching, `yield` deps for DB sessions,140 router/service/repository layering, composition root, `dependency_overrides`).141- **`references/database.md`** — Read when adding persistence (Ch 5: session-per-142 request, N+1 + `selectinload`/`joinedload`, SQLModel vs SQLAlchemy+Pydantic,143 Alembic, pool sizing, PgBouncer, `flush` vs `commit`).144- **`references/security.md`** — Read when adding auth or hardening (Ch 6: password145 hashing, JWT + revocation, API keys, CORS, rate limiting, OWASP API risks, BOLA).146- **`references/testing.md`** — Read when writing tests (Ch 7: `TestClient`,147 `dependency_overrides`, SQLite-in-memory tiers, authz matrix, time injection,148 coverage honesty, 422-body assertions).149- **`references/async-and-external.md`** — Read when doing concurrency or external150 calls (Ch 8: event loop model, the full `def`/`async` table, `httpx` client +151 retries + circuit breaker, `BackgroundTasks` vs Celery, webhooks, uploads,152 streaming, WebSockets, backpressure).153- **`references/ai-ml-serving.md`** — Read when serving models or LLMs (Ch 9:154 model loading, batch + dynamic micro-batching, LLM gateway + SSE streaming,155 embeddings + vector DB + RAG, guardrails, validation loop, versioning/monitoring/156 HITL, LLM cost model).157- **`references/deployment-observability.md`** — Read when deploying or operating158 (Ch 10: Uvicorn/Gunicorn workers, Docker, K8s probes, logs/metrics/traces,159 caching + stampede, load testing, graceful degradation, production checklist).160161## Authoritative sources (link, don't paraphrase from memory)162163FastAPI's API surface and the surrounding ecosystem change between minor164versions. When unsure of exact syntax or current behavior, fetch the docs:165166- FastAPI: <https://fastapi.tiangolo.com>167- Pydantic v2: <https://docs.pydantic.dev>168- Starlette: <https://www.starlette.io>169- SQLModel: <https://sqlmodel.tiangolo.com>170- httpx: <https://www.python-httpx.org>171172## Gotchas173174The top, cross-cutting gotchas are in the section above (they apply before you175even pick a topic). Topic-specific traps live in each reference's own `## Gotchas`176section — for example, `flush()` vs `commit()` timing in `database.md`, the177`alg=none` attack in `security.md`, and SSE proxy-buffering in178`ai-ml-serving.md`. Load the reference for the area you're working in so you read179its gotchas **before** hitting the situation, not after.