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 /docs and 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 — set from_attributes=True (formerly orm_mode) to serialize SQLAlchemy rows.
  • Computed fields@computed_field derives totals from line items without storing redundant columns.
  • Discriminated unions — model polymorphic webhook payloads with a type literal field.
  • SettingsBaseSettings loads 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 cursor and limit query params into a reusable Pagination dataclass.
  • Idempotency key — read Idempotency-Key header, 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.

  1. Project layoutapp/main.py (FastAPI factory), routers/orders.py, schemas/order.py (Pydantic), models/order.py (SQLAlchemy), deps.py (db, auth, idempotency).
  2. Create flowPOST /api/v1/orders validates OrderCreate, checks Idempotency-Key in Redis, inserts order + lines in one transaction, returns OrderPublic with 201.
  3. State machinePATCH /orders/{id}/status (internal) transitions pending → authorized → captured; invalid transitions return 409 with machine-readable error codes.
  4. Webhooks — background task signs HMAC payload and POSTs to partner URLs; failures retry via Celery with exponential backoff.
  5. Observability — middleware injects X-Request-ID; structured JSON logs include order_id and latency; Prometheus metrics via prometheus-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 requirementsMinimal surface area and explicit control over every layer matter mostAdmin UI, auth, ORM migrations, and templates ship together
Handlers await async database and HTTP I/OTeam avoids async/await complexity entirelyMonolithic product with Django REST Framework is already standard
Pydantic validation replaces ad-hoc request parsingLegacy WSGI middleware ecosystem is non-negotiableBuilt-in user model and session auth cover most needs
Microservices expose small, documented HTTP contractsPrototype size is a single file under 100 linesContent management and server-rendered pages dominate
Python ML services need inference endpoints beside batch jobsDeployment 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() or time.sleep() stalls all concurrent requests; use httpx async client or run_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_db and auth deps with fakes.

Practitioner checklist

  • Structure apps with APIRouter modules; keep main.py thin.
  • Validate every public input with Pydantic; separate create, update, and public response schemas.
  • Inject database sessions via Depends with 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_event with lifespan for startup/shutdown hooks.
  • Write async integration tests with httpx.AsyncClient and dependency overrides.
  • Export OpenAPI to CI artifacts; diff on pull requests when external integrators depend on the spec.
  • Run behind Uvicorn + reverse proxy; expose /health for 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