Guide
FastAPI fundamentals explained
A payments team ships a new order API in Python. The first draft uses
Flask with manual
JSON parsing, no request schema, and handwritten OpenAPI that drifts from reality
within a week. Refactoring to FastAPI collapses validation,
serialization, and API documentation into one layer: declare a Pydantic model, attach
it to a path operation, and the framework returns 422 with structured
errors before your handler runs — while auto-generating an interactive Swagger UI
at /docs. Built on Starlette’s ASGI stack, FastAPI supports
async def handlers that await database pools and upstream HTTP without
blocking worker threads. This guide covers path operations and routing, Pydantic v2
models, dependency injection, async
SQLAlchemy
patterns, background tasks, OpenAPI customization, a Harbor Payments order API worked
example, a framework decision table versus Flask and
Hono, common
pitfalls, and a production checklist — assuming baseline
Python fluency.
What FastAPI is and when to adopt it
FastAPI is a modern ASGI web framework created by Sebastián Ramírez. It sits on Starlette (routing, middleware, WebSockets) and Pydantic (data validation and settings). Unlike WSGI frameworks that process one request per thread, ASGI handlers yield control while awaiting I/O — critical when each request fans out to PostgreSQL, Redis, and a payment gateway.
Core primitives
- Path operation — a decorated function (
@app.get,@app.post) bound to an HTTP method and URL pattern. - Pydantic model — a typed class describing request bodies, query params, or response shapes with automatic validation.
- Dependency — a callable injected into handlers via
Depends()for database sessions, auth, pagination, and shared logic. - APIRouter — a sub-application mounted under a prefix (
/api/v1/orders) to modularize large codebases. - OpenAPI schema — machine-readable contract generated from type hints and Pydantic models; powers
/docsand client codegen.
FastAPI excels when you need typed REST or JSON-RPC-style APIs, automatic OpenAPI for partner integrations, and async database access on modest hardware. Choose Django when admin panels, ORM migrations, and server-rendered templates dominate the product. Choose Flask for minimal microservices where you prefer explicit control over every layer and validation is thin. Choose Hono or Fastify when the team is TypeScript-first and Python is not in the deployment stack.
Path operations and routing
A minimal FastAPI application exposes ASGI via Uvicorn (or Hypercorn):
from fastapi import FastAPI
app = FastAPI(title="Harbor Payments", version="1.0.0")
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/orders/{order_id}")
async def get_order(order_id: str):
return {"order_id": order_id, "status": "pending"}
Path parameters, query strings, and headers are declared as function parameters with type annotations. FastAPI coerces types and validates constraints:
from fastapi import Query
@app.get("/orders")
async def list_orders(
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
status: str | None = None,
):
return {"page": page, "limit": limit, "status": status}
Group routes with APIRouter and include them on the main app:
from fastapi import APIRouter
orders_router = APIRouter(prefix="/orders", tags=["orders"])
@orders_router.post("/")
async def create_order(body: OrderCreate):
...
app.include_router(orders_router, prefix="/api/v1")
Response models and status codes
Declare return types with Pydantic models or use response_model= to
filter sensitive fields from serialized output. Set status codes via
@app.post("/", status_code=201) or return a JSONResponse
with explicit codes for error paths. Use HTTPException for consistent
404 and 403 responses without building response dicts manually.
Pydantic v2 models and validation
Request bodies map to Pydantic BaseModel subclasses. Invalid payloads
never reach your handler:
from pydantic import BaseModel, Field, EmailStr
from decimal import Decimal
class OrderLine(BaseModel):
sku: str = Field(min_length=1, max_length=64)
quantity: int = Field(ge=1, le=999)
unit_price: Decimal = Field(gt=0, decimal_places=2)
class OrderCreate(BaseModel):
customer_email: EmailStr
currency: str = Field(pattern=r"^[A-Z]{3}$")
lines: list[OrderLine] = Field(min_length=1)
Pydantic v2 uses a Rust core (pydantic-core) for faster validation.
Key patterns for APIs:
model_config— setfrom_attributes=True(formerlyorm_mode) to serialize SQLAlchemy rows.- Computed fields —
@computed_fieldderives totals from line items without storing redundant columns. - Discriminated unions — model polymorphic webhook payloads with a
typeliteral field. - Settings —
BaseSettingsloads env vars for database URLs and API keys with validation at startup.
Return models should exclude internal IDs or cost fields the client must not see.
Use response_model_exclude or separate OrderPublic /
OrderInternal schemas rather than filtering dicts by hand in every
route.
Dependency injection
Depends() is FastAPI’s composable middleware alternative.
Dependencies can depend on other dependencies, forming a directed acyclic graph
resolved per request:
from fastapi import Depends, Header, HTTPException
async def verify_api_key(x_api_key: str = Header()):
if x_api_key != settings.internal_api_key:
raise HTTPException(status_code=401, detail="Invalid API key")
async def get_db():
async with async_session() as session:
yield session
@app.post("/api/v1/orders", dependencies=[Depends(verify_api_key)])
async def create_order(
body: OrderCreate,
db: AsyncSession = Depends(get_db),
):
...
Common dependency patterns:
- Database session — yield an async session; commit on success, rollback on exception; close in
finally. - Current user — decode JWT from
Authorization, load user row, raise 401 if expired. - Pagination — parse
cursorandlimitquery params into a reusablePaginationdataclass. - Idempotency key — read
Idempotency-Keyheader, check Redis for prior response, short-circuit duplicates.
Class-based dependencies (class Paginator: def __call__(self, ...))
hold configurable defaults. Use scope="function" (default) for
per-request state; avoid storing mutable request data on app-global singletons.
Async SQLAlchemy and database access
Pair FastAPI with SQLAlchemy 2.0 async engine and
asyncpg for
PostgreSQL:
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
engine = create_async_engine(settings.database_url, pool_size=10)
async_session = async_sessionmaker(engine, expire_on_commit=False)
async def get_db():
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
Handlers use await session.execute(select(Order).where(...)) and
await session.scalar(...). Never call blocking session.query()
from sync SQLAlchemy inside async def routes — it blocks the event
loop. For legacy sync ORM code, run queries in
asyncio.to_thread() or migrate to async sessions.
Connection pooling and transactions
Size pools to expected concurrency (Uvicorn workers times in-flight requests per worker). Use explicit transactions for multi-step writes: create order header, insert lines, reserve inventory — all in one commit. Read replicas can be injected as a separate dependency for list endpoints that tolerate replication lag.
Background tasks, middleware, and lifespan
BackgroundTasks runs work after the response is sent — suitable
for sending receipt emails or enqueueing webhooks, not for heavy CPU jobs (use
Celery or a
queue instead):
from fastapi import BackgroundTasks
@app.post("/orders")
async def create_order(body: OrderCreate, bg: BackgroundTasks):
order = await save_order(body)
bg.add_task(send_confirmation_email, order.id)
return order
ASGI middleware handles cross-cutting concerns: CORS (CORSMiddleware),
gzip, trusted hosts, and request ID propagation. Use the lifespan context
manager (replacing deprecated @app.on_event) to warm connection pools
and close engines on shutdown:
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
await engine.connect() # verify DB reachable
yield
await engine.dispose()
app = FastAPI(lifespan=lifespan)
OpenAPI, testing, and deployment
FastAPI generates OpenAPI 3.1 from your type hints. Customize metadata with
app = FastAPI(title=..., description=..., version=...). Tag routes
for grouped Swagger sections. Disable docs in production with
docs_url=None if the spec must not be public, or protect
/docs behind auth middleware.
Test with TestClient (sync wrapper) or httpx.AsyncClient
with ASGITransport:
from httpx import ASGITransport, AsyncClient
async def test_create_order():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
res = await client.post("/api/v1/orders", json={...}, headers={"X-API-Key": "test"})
assert res.status_code == 201
Deploy behind Uvicorn with multiple workers (uvicorn main:app --workers 4)
or Gunicorn with uvicorn.workers.UvicornWorker. Container images should
run as non-root, expose a health endpoint for orchestrator probes, and set
WEB_CONCURRENCY from CPU count. Pair with
nginx or a cloud
load balancer for TLS termination.
Worked example: Harbor Payments order API
Harbor Payments processes B2B invoices for the Harbor Commerce marketplace. The order API must accept multi-line orders, enforce idempotency for partner retries, persist to PostgreSQL, emit webhooks on settlement, and expose OpenAPI for three external integrators. Requirements: sub-200 ms p95 on create, API key auth, and audit logs for every state transition.
- Project layout —
app/main.py(FastAPI factory),routers/orders.py,schemas/order.py(Pydantic),models/order.py(SQLAlchemy),deps.py(db, auth, idempotency). - Create flow —
POST /api/v1/ordersvalidatesOrderCreate, checksIdempotency-Keyin Redis, inserts order + lines in one transaction, returnsOrderPublicwith 201. - State machine —
PATCH /orders/{id}/status(internal) transitionspending → authorized → captured; invalid transitions return 409 with machine-readable error codes. - Webhooks — background task signs HMAC payload and POSTs to partner URLs; failures retry via Celery with exponential backoff.
- Observability — middleware injects
X-Request-ID; structured JSON logs include order_id and latency; Prometheus metrics viaprometheus-fastapi-instrumentator.
Integration tests spin up a test database in Docker, override get_db with
a test session fixture, and cover idempotent replays, validation 422s, and auth 401s.
Staging deploys disable public docs but export OpenAPI to a CI artifact for partner
diff review on each release.
Framework decision table
| Choose FastAPI when… | Prefer Flask when… | Prefer Django when… |
|---|---|---|
| Typed JSON APIs and OpenAPI are first-class requirements | Minimal surface area and explicit control over every layer matter most | Admin UI, auth, ORM migrations, and templates ship together |
| Handlers await async database and HTTP I/O | Team avoids async/await complexity entirely | Monolithic product with Django REST Framework is already standard |
| Pydantic validation replaces ad-hoc request parsing | Legacy WSGI middleware ecosystem is non-negotiable | Built-in user model and session auth cover most needs |
| Microservices expose small, documented HTTP contracts | Prototype size is a single file under 100 lines | Content management and server-rendered pages dominate |
| Python ML services need inference endpoints beside batch jobs | Deployment target only supports sync WSGI (rare today) | Long-term ORM and migration history live in Django models |
Common pitfalls
- Blocking calls in async routes — sync
requests.get()ortime.sleep()stalls all concurrent requests; usehttpxasync client orrun_in_executor. - Sharing mutable global state — counters and caches on module globals race across workers; use Redis or database rows.
- Leaking ORM objects in responses — lazy-loaded relationships trigger N+1 queries during serialization; eager-load or use explicit response models.
- Oversized default body limits — large file uploads need streaming endpoints, not loading entire payloads into Pydantic models.
- BackgroundTasks for critical work — tasks die if the process crashes after responding; use a durable queue for payments and webhooks.
- Drifting OpenAPI from behavior — changing response shapes without updating Pydantic models breaks generated clients; treat models as the contract.
- Too many Uvicorn workers — each worker holds a connection pool; workers times pool size can exhaust PostgreSQL
max_connections. - Skipping dependency overrides in tests — hitting production DB in CI; override
get_dband auth deps with fakes.
Practitioner checklist
- Structure apps with
APIRoutermodules; keepmain.pythin. - Validate every public input with Pydantic; separate create, update, and public response schemas.
- Inject database sessions via
Dependswith yield-based cleanup and rollback. - Use async SQLAlchemy 2.0 for new PostgreSQL services; size connection pools per worker count.
- Implement idempotency keys on partner-facing POST endpoints.
- Configure CORS, trusted hosts, and security headers middleware before route handlers.
- Replace
@app.on_eventwithlifespanfor startup/shutdown hooks. - Write async integration tests with
httpx.AsyncClientand dependency overrides. - Export OpenAPI to CI artifacts; diff on pull requests when external integrators depend on the spec.
- Run behind Uvicorn + reverse proxy; expose
/healthfor liveness and readiness probes.
Key takeaways
- FastAPI combines ASGI async performance with Pydantic validation and automatic OpenAPI documentation.
- Path operations use Python type hints for params, bodies, and responses — invalid requests fail fast with structured 422 errors.
- Dependency injection composes auth, database sessions, and pagination without nested middleware boilerplate.
- Async SQLAlchemy pairs naturally with FastAPI when handlers await I/O-bound database work.
- Use background tasks and queues appropriately — fire-and-forget for emails, durable queues for money-moving side effects.
Related reading
- Python fundamentals explained — language basics before async API patterns
- SQLAlchemy fundamentals explained — ORM and async session patterns for FastAPI services
- Flask fundamentals explained — minimal WSGI alternative when async is not required
- REST API design explained — resource modeling and HTTP semantics beyond framework choice