Setting Up FastAPI for Builders: A Step-by-Step Guide

Most FastAPI tutorials stop at a decorated function that returns a dictionary. That gets you a demo. It does not get you a service you can charge for, because the parts that decide whether your API survives its first paying customer — configuration loading, connection reuse, error contracts, worker counts — all sit outside the hello-world snippet. This page builds the other 90%. Part of the Getting Started with Python APIs for Builders guide, which covers the protocol choices and HTTP fundamentals this page assumes you have already settled.

The recommendation up front: run one FastAPI application object, load settings once into a cached singleton, open every shared client inside a lifespan handler, and start with exactly as many Uvicorn workers as you have vCPUs. That combination costs you an afternoon and buys you a service that handles a few thousand requests per second on a $12 container without a single architectural rewrite until you are well past your first thousand customers.

FastAPI earns its place as the default for commercial Python APIs on four concrete grounds. Async-first execution means one process holds thousands of open connections while waiting on a database or an upstream vendor, so your concurrency ceiling is set by I/O, not by thread count. The OpenAPI schema is generated from the same type hints that validate your inputs, so your published documentation cannot silently drift from the running code. Pydantic v2 rejects malformed payloads in compiled Rust before your handler burns a millisecond of CPU. And dependency injection gives you one honest place to enforce authentication and quota, which is exactly where your billing logic will eventually live.

What Actually Happens to a Request

Understanding the request pipeline is the difference between debugging FastAPI and guessing at it. A request does not go straight to your function. It passes through an ordered stack, and every stage can end the request early.

Uvicorn parses the HTTP bytes and hands an ASGI scope to Starlette, which FastAPI is built on. Middleware runs next, outermost first — CORS, GZip, your request-timing wrapper. The router matches the path and method. Dependencies resolve in declaration order, so an authentication dependency that raises never lets the rest of the chain run. Then Pydantic validates the path parameters, query string, and body against your annotations. Only after all of that does your handler execute, and its return value goes back through the response model for filtering and serialization.

Two consequences matter commercially. First, a request that fails validation costs you almost nothing — Pydantic v2 rejects a bad body in microseconds, so an abusive client sending garbage cannot drive your compute bill up. Second, and less pleasantly, a blocking call inside an async def handler stops the entire event loop for that worker. Not the request. The worker. Every other in-flight request on that process waits. This is the single most expensive mistake in the framework, and it is why httpx rather than requests is non-negotiable for outbound calls from async code.

FastAPI does give you an escape hatch: declare a handler as plain def instead of async def and Starlette runs it in a thread pool, where blocking is safe. Use it deliberately for a synchronous SDK you cannot replace. Never mix the two mental models inside one function.

The FastAPI request pipeline and its validation short-circuit A request flows through the ASGI server, middleware stack, dependency resolution and Pydantic validation before reaching the handler, then back out through the response model and JSON encoder; a failed validation skips the handler entirely and returns 422. One request through a FastAPI worker ASGI server uvicorn + uvloop Middleware CORS, GZip, timing Dependencies auth, quota, session Validation Pydantic v2 core 422: handler never runs Handler your async logic response_model drops extra fields JSON encode status + headers Client bytes on the wire Everything above shares one event loop per worker process. A blocking call inside an async handler stalls every other in-flight request, not just its own. Use a plain def handler when you must block.

Prerequisites

Everything here targets Python 3.11 or newer. Install fastapi, uvicorn[standard], pydantic-settings, httpx, and gunicorn if you deploy to a Linux host. The [standard] extra pulls in uvloop and httptools, which are worth roughly 20–30% on request throughput for free on Linux and macOS.

You will need three environment variables before the first snippet runs: DATABASE_URL, UPSTREAM_BASE_URL, and API_ENV. Nothing on this page hardcodes a URL, a key, or a timeout — every value resolves from the environment with a sane default, because the moment you hardcode a hostname you have created a deploy that only works on your laptop.

Step 1: Lay Out the Project So Growth Does Not Hurt

Single-file APIs are fine until the second developer or the tenth endpoint, whichever arrives first. Split by responsibility from day one; it costs nothing now and saves a painful refactor later. The layout below is the one I ship with, and it maps cleanly onto how container image layers cache so your builds stay fast.

Text
my_api/
├── app/
│   ├── __init__.py
│   ├── main.py          # app object, lifespan, middleware, router mounts
│   ├── config.py        # Settings singleton, read once
│   ├── deps.py          # shared dependencies: auth, db session, http client
│   ├── errors.py        # exception handlers and the error envelope
│   ├── routers/         # one module per resource
│   └── schemas/         # Pydantic request/response models
├── tests/
├── pyproject.toml
├── .env.example         # committed; .env itself never is
└── Dockerfile

The rule that keeps this honest: main.py must contain no business logic. It builds the app, attaches middleware and handlers, and includes routers. If you can read it in twenty seconds, the structure is working. Routers import from schemas and deps, never from each other, which is what lets you split a router into its own service later without unpicking a web of imports.

Step 2: Read Configuration Once, at Startup

Calling os.getenv inside a route handler is a small crime that compounds. It scatters defaults across the codebase, hides typos until the exact request that needs them, and gives you no single place to validate that production is configured correctly. Load everything once into a Pydantic settings object, cache it, and inject it.

Pydantic settings resolve in a fixed precedence order: an explicit init argument beats an environment variable, which beats a value in the .env file, which beats the field default. That order is what makes tests easy — construct Settings(api_env="test") and you have overridden production config without touching the process environment.

Python
import os
import tomllib
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


def _project_version() -> str:
    """Read the version from pyproject.toml so /docs never lies."""
    pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
    if not pyproject.exists():
        return os.getenv("APP_VERSION", "0.0.0")
    with pyproject.open("rb") as fh:
        return tomllib.load(fh)["project"]["version"]


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    api_env: str = Field(default="development")
    database_url: str = Field(default="sqlite+aiosqlite:///./app.db")
    upstream_base_url: str = Field(default="https://example.invalid")
    upstream_timeout: float = Field(default=5.0, gt=0)
    pool_max_connections: int = Field(default=20, ge=1)
    worker_count: int = Field(default=2, ge=1)
    version: str = Field(default_factory=_project_version)

    @property
    def docs_url(self) -> str | None:
        # Public schema browsing in production is an information leak.
        return "/docs" if self.api_env != "production" else None


@lru_cache
def get_settings() -> Settings:
    return Settings()

The lru_cache decorator matters more than it looks. Without it, every request that depends on settings re-reads and re-parses the .env file from disk — a syscall per request, which shows up as a flat few hundred microseconds of latency you did not need to pay. With it, the object is built once per worker process and shared.

Precedence order when Settings resolves a configuration value Four ranked sources for a setting: an explicit init argument wins, then a process environment variable, then the dotenv file, then the field default declared on the Settings model. How Settings resolves upstream_timeout 1 Settings(upstream_timeout=1.0) Explicit argument always wins. Use it in tests, nowhere else. 2 UPSTREAM_TIMEOUT in os.environ The production path. Set by the platform secret store on deploy. 3 .env file on disk Local development only. Never commit it to git. 4 Field(default=5.0) on the model The last line of defence. Make it safe, not convenient. Resolved once per worker and cached — not once per request.

Step 3: Model the Payloads Before You Model the Database

Your request and response schemas are the public contract you will be held to. Design them first, and keep them separate from your ORM models — the day you rename a database column, you do not want every customer's integration to break. That separation is also what makes versioning a public API survivable rather than terrifying.

Pydantic v2 does the enforcement. Use Annotated types so the constraint travels with the type instead of living in the default value, set extra="forbid" on inputs so a client typo produces a loud 422 instead of a silently ignored field, and always declare a response_model so an accidental password_hash in a dictionary never reaches the wire. If field constraints are new to you, the deeper treatment lives in validating JSON with Pydantic v2.

Python
from decimal import Decimal
from typing import Annotated
from uuid import UUID, uuid4

from fastapi import APIRouter, Depends, Path, Query, status
from pydantic import BaseModel, ConfigDict, Field

from app.config import Settings, get_settings

router = APIRouter(prefix="/items", tags=["items"])

Name = Annotated[str, Field(min_length=2, max_length=50)]
Price = Annotated[Decimal, Field(gt=0, max_digits=10, decimal_places=2)]


class ItemCreate(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: Name
    price: Price
    category: str | None = None


class ItemOut(BaseModel):
    id: UUID
    name: Name
    price: Price
    category: str | None = None


@router.post("", response_model=ItemOut, status_code=status.HTTP_201_CREATED)
async def create_item(
    payload: ItemCreate,
    settings: Annotated[Settings, Depends(get_settings)],
) -> ItemOut:
    # Persist here; the response model guarantees the shape either way.
    return ItemOut(id=uuid4(), **payload.model_dump())


@router.get("/{item_id}", response_model=ItemOut)
async def get_item(
    item_id: Annotated[UUID, Path(description="Item identifier")],
    verbose: Annotated[bool, Query(description="Include derived fields")] = False,
) -> ItemOut:
    return ItemOut(id=item_id, name="Sample Item", price=Decimal("9.99"))

Note the Decimal for price. Floats are the wrong type for money — 0.1 + 0.2 is not 0.3, and a rounding drift of a hundredth of a cent per transaction becomes a reconciliation problem the first time an accountant looks at your Stripe payouts. Pydantic serializes Decimal to a JSON number cleanly, so the fix costs you one import.

Step 4: Give Every Shared Client a Lifespan

The most common performance bug in a young FastAPI service is creating an httpx.AsyncClient inside a handler. Each one opens a fresh TCP connection and repeats the TLS handshake — 60 to 150 ms of pure latency, per request, that a pooled client would have amortised to zero. The same applies to database engines, Redis connections, and vendor SDKs.

FastAPI's lifespan context manager is the right home for all of them. Code before the yield runs once at process start; code after runs at shutdown. Store the objects on app.state and hand them to routes through dependencies, which keeps handlers testable and makes the connection lifecycle explicit rather than accidental.

Python
import logging
from contextlib import asynccontextmanager
from typing import Annotated, AsyncIterator

import httpx
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.gzip import GZipMiddleware

from app.config import get_settings
from app.routers import items

logger = logging.getLogger("app")


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    settings = get_settings()
    limits = httpx.Limits(
        max_connections=settings.pool_max_connections,
        max_keepalive_connections=settings.pool_max_connections // 2,
    )
    app.state.http = httpx.AsyncClient(
        base_url=settings.upstream_base_url,
        timeout=httpx.Timeout(settings.upstream_timeout, connect=2.0),
        limits=limits,
    )
    logger.info("startup complete env=%s version=%s", settings.api_env, settings.version)
    try:
        yield
    finally:
        await app.state.http.aclose()


def get_http(request: Request) -> httpx.AsyncClient:
    return request.app.state.http


settings = get_settings()
app = FastAPI(
    title="Builder API",
    version=settings.version,
    docs_url=settings.docs_url,
    redoc_url=None,
    lifespan=lifespan,
)
app.add_middleware(GZipMiddleware, minimum_size=1024)
app.include_router(items.router)


@app.get("/healthz", include_in_schema=False)
async def healthz(client: Annotated[httpx.AsyncClient, Depends(get_http)]) -> dict[str, str]:
    return {"status": "ok", "version": settings.version}

Two details earn their keep. GZipMiddleware with a 1 KB floor compresses JSON responses at roughly 6:1 — on a 14 KB payload served ten million times that is over 100 GB of egress you stop paying for, at a CPU cost you will not measure. And the explicit connect=2.0 timeout separates "the upstream is slow" from "the upstream is unreachable", which are different incidents deserving different retry behaviour; the strategies live in retrying failed HTTP requests with tenacity.

Process-lifetime resources versus request-scoped borrowing A worker process opens the HTTP client and database engine once at startup, each request borrows a pooled connection through a dependency and returns it when the response is sent, and shutdown closes everything exactly once. What lives for the process, what lives for one request Worker process lifetime startup AsyncClient + engine pools opened once request A: borrow request B: borrow request C: borrow shutdown aclose(), dispose() drains in-flight work Connections return to the pool, not to the operating system. A client built inside a handler pays TLS setup every time: 60-150 ms wasted. Pool size caps real concurrency — size it above your peak, below the server limit. Multiply every pool number by your worker count before comparing to the database limit.

Step 5: Turn Failures Into Contracts

Customers integrate against your errors as much as your successes. An error that changes shape between endpoints forces every client to write defensive parsing, and that friction shows up as support tickets and churn. Pick one envelope and enforce it globally.

Three exception classes cover almost everything. HTTPException is your deliberate refusal — 404, 409, 402 when a quota is exhausted. RequestValidationError is Pydantic rejecting a payload, and FastAPI already turns it into a 422 with a field-level detail list you should keep rather than flatten. Everything else is a bug, and the only correct response is a 500 with a correlation id you can grep for, never a traceback. A match statement over the exception type keeps that mapping in one readable place.

Python
import logging
import uuid

from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from httpx import ConnectError, TimeoutException

logger = logging.getLogger("app")


def _envelope(code: str, message: str, request_id: str, extra: dict | None = None) -> dict:
    body = {"error": {"code": code, "message": message, "request_id": request_id}}
    if extra:
        body["error"].update(extra)
    return body


def register_error_handlers(app: FastAPI) -> None:
    @app.middleware("http")
    async def attach_request_id(request: Request, call_next):
        request.state.request_id = request.headers.get("x-request-id", uuid.uuid4().hex)
        response = await call_next(request)
        response.headers["x-request-id"] = request.state.request_id
        return response

    @app.exception_handler(HTTPException)
    async def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
        rid = getattr(request.state, "request_id", "unknown")
        return JSONResponse(
            status_code=exc.status_code,
            content=_envelope("http_error", str(exc.detail), rid),
            headers=exc.headers or {},
        )

    @app.exception_handler(RequestValidationError)
    async def handle_validation(request: Request, exc: RequestValidationError) -> JSONResponse:
        rid = getattr(request.state, "request_id", "unknown")
        fields = [
            {"field": ".".join(str(p) for p in err["loc"][1:]), "reason": err["msg"]}
            for err in exc.errors()
        ]
        return JSONResponse(
            status_code=422,
            content=_envelope("invalid_request", "Payload failed validation", rid, {"fields": fields}),
        )

    @app.exception_handler(Exception)
    async def handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
        rid = getattr(request.state, "request_id", "unknown")
        match exc:
            case TimeoutException():
                status, code, message = 504, "upstream_timeout", "Upstream did not respond in time."
            case ConnectError():
                status, code, message = 502, "upstream_unavailable", "Upstream is unreachable."
            case _:
                status, code, message = 500, "internal_error", "Something broke on our side."
        logger.exception("request_failed rid=%s path=%s", rid, request.url.path)
        return JSONResponse(status_code=status, content=_envelope(code, message, rid))

Returning the request id in both the body and the x-request-id header is the highest-leverage twenty lines in this file. When a customer emails you "your API returned an error at 14:32", one grep resolves it. Without it you are reconstructing timelines from timestamps. Pair it with structured logging so the id is a queryable field rather than a substring buried in a text line.

Mapping raised exceptions to a single error envelope A decision tree: a deliberate HTTPException keeps its own status, a Pydantic validation error becomes 422 with field details, an upstream timeout or connection failure becomes 504 or 502, and anything else becomes a 500 carrying a request id. Every failure leaves through one envelope Exception in a route HTTPException ValidationError httpx timeout any bug your status code detail passed through 422 + field list no compute burned 504 or 502 safe to retry 500 + log never a traceback All four carry the same request_id, in the body and the x-request-id header.

Step 6: Configure the Production Runtime

Development runs uvicorn app.main:app --reload. Production does not. Reload watches the filesystem, holds extra memory, and disables the optimisations you are paying for. Ship a process manager instead: Gunicorn supervising Uvicorn workers gives you graceful restarts and a worker that gets replaced when it dies, which matters more than the last 3% of throughput.

Worker count is where builders lose money in both directions. Each worker is a full Python process with its own interpreter, its own event loop, and its own copy of every connection pool. Two workers with a pool of 20 open 40 database connections, and a small managed Postgres caps out around 100 — exceed it and you get the classic connection pool exhaustion stall where healthy requests queue behind nothing. Start at one worker per vCPU, measure, and only then adjust. The deeper trade-offs are in Uvicorn vs Gunicorn worker configuration.

Python
import os

import uvicorn

if __name__ == "__main__":
    uvicorn.run(
        "app.main:app",
        host=os.getenv("HOST", "0.0.0.0"),
        port=int(os.getenv("PORT", "8000")),
        workers=int(os.getenv("WORKER_COUNT", "2")),
        log_level=os.getenv("LOG_LEVEL", "warning"),
        access_log=os.getenv("ACCESS_LOG", "0") == "1",
        proxy_headers=True,
        forwarded_allow_ips=os.getenv("FORWARDED_ALLOW_IPS", "*"),
        timeout_graceful_shutdown=int(os.getenv("GRACEFUL_TIMEOUT", "20")),
    )

Set proxy_headers=True whenever a load balancer sits in front, or every client IP in your logs and rate limiter becomes the proxy's address — which quietly breaks per-customer throttling. Set timeout_graceful_shutdown above your slowest endpoint so a deploy drains in-flight requests instead of severing them; that single flag is most of what zero-downtime deploys require. Disable the access log in production and let your structured logger own that output — Uvicorn's default access log writes synchronously to stdout and costs real throughput under load.

The numbers below come from a JSON echo endpoint on a 2 vCPU, 1 GB container. The shape generalises: throughput tracks vCPU count and then flattens, while memory climbs linearly forever.

Throughput and memory against Uvicorn worker count Bar chart of requests per second on a 2 vCPU container: 4100 with one worker, 7600 with two, 7900 with four, and 6900 with eight, while resident memory rises from 180 MB to 1290 MB across the same range. Throughput vs worker count on a 2 vCPU container requests / second 4,100 7,600 7,900 6,900 1 worker 2 workers 4 workers 8 workers 180 MB 340 MB 660 MB 1,290 MB Two workers is the sweet spot: 4% off peak throughput at half the memory of four.

Configuration Reference

Every value below reads from the environment. Commit the table to your .env.example so a new deploy target is a copy-paste rather than an archaeology exercise.

VariableDefaultProduction setting
API_ENVdevelopmentproduction — also hides /docs
DATABASE_URLlocal SQLiteasync driver URL from the platform
UPSTREAM_TIMEOUT5.03.0 if you retry, 10.0 if not
POOL_MAX_CONNECTIONS20server limit ÷ worker count, minus 5
WORKER_COUNT2one per vCPU
LOG_LEVELwarningwarning; info only while debugging
GRACEFUL_TIMEOUT20above your slowest endpoint's p99

The pool sizing rule deserves the arithmetic. If your managed Postgres allows 100 connections and you run four workers, each worker may hold at most 25 — and you should reserve a handful for migrations and your own psql session, so 20 is the honest number. Get this wrong and your API fails under exactly the traffic you were hoping for.

Gotchas and Failure Modes

Blocking inside async def. Calling requests.get, time.sleep, or a synchronous database driver in an async handler freezes the whole worker's event loop. Symptom: p50 latency stays fine while p99 explodes, and throughput collapses under concurrency that used to be comfortable. Fix by switching to async libraries, or move the call to a plain def handler so Starlette runs it on the thread pool.

Mutable default arguments in dependencies. A dependency with def get_flags(cache: dict = {}) shares that dictionary across every request for the life of the process. One customer's data leaks into another's response, and it will not reproduce locally where you only ever send one request.

Forgetting response_model. Returning an ORM object or a raw dictionary serializes whatever attributes exist, including the ones added by a future migration. Declare the response model and internal fields cannot escape by accident.

Trusting the client's Content-Length for uploads. FastAPI will happily buffer a 2 GB body into memory. Cap it in the reverse proxy and, for genuinely large bodies, stream the payload instead of parsing it whole.

Leaving /docs public in production. Your schema is a map of every endpoint, parameter, and enum you support. That is a gift to anyone probing for weak spots and a leak of your roadmap. Gate it behind auth or disable it, as the settings property above does.

Skipping authentication until "later". Retrofitting auth across twenty routes is a week of work and a certain regression. Add the dependency to the router the day you create it, following handling API authentication in Python.

Verification

Start the service and confirm three things: it boots, it rejects bad input, and it reuses connections. The first two take a curl each.

Bash
curl -s localhost:8000/healthz
# {"status":"ok","version":"1.0.0"}

curl -s -X POST localhost:8000/items \
  -H 'content-type: application/json' \
  -d '{"name":"x","price":-1,"colour":"red"}' | python3 -m json.tool
# 422 with three field errors: name too short, price not > 0, colour forbidden

The third needs a test. Use httpx.ASGITransport so requests hit the app in-process without a live socket, which keeps the suite fast and deterministic — the full pattern is in testing async FastAPI endpoints with httpx.

Python
import pytest
from httpx import ASGITransport, AsyncClient

from app.main import app


@pytest.mark.anyio
async def test_rejects_unknown_field() -> None:
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.post("/items", json={"name": "Widget", "price": 5, "x": 1})
    assert response.status_code == 422
    assert response.json()["error"]["code"] == "invalid_request"
    assert "request_id" in response.json()["error"]

If that test passes, your validation, your error envelope, and your lifespan all work together. Wire it into CI before you add a second endpoint; the broader suite design lives in testing Python APIs with pytest.

Cost and Performance at Scale

Put real numbers on the setup. A two-worker FastAPI service on a $12/month container with 2 vCPUs sustains roughly 7,600 requests per second on trivial JSON and, more realistically, 900–1,400 requests per second once each request makes one 8 ms database round trip. That is 2.3 billion requests a month at full saturation, which nobody reaches — but it means 10 million monthly requests uses about 2% of the box. Your compute cost per request at that volume is roughly $0.0000012. If you charge a tenth of a cent per call, your gross margin on compute alone is above 99%.

The costs that actually bite sit elsewhere. Egress is usually first: 10 million responses averaging 14 KB is 140 GB, which is $12 to $18 on most platforms — more than the server. GZip at a 6:1 ratio drops that to about $2 to $3, which is why the middleware is not optional. Managed Postgres is next, and it scales with connection count and IOPS rather than request count, so the pool arithmetic above is a billing decision disguised as a config value. Third-party calls are third: if each request triggers one vendor API call at $0.001, that vendor is now 99% of your unit cost, and caching it in Redis with even a 60-second TTL is the single highest-margin change available to you.

Worker count deserves one more pass in money terms. Going from two workers to four on the same 2 vCPU box bought 4% throughput and cost 320 MB — enough to push a 1 GB container into swap under load, where p99 latency goes from 40 ms to several seconds and your uptime page turns red. The cheaper move is always to fix the blocking call or add the cache before adding a worker. When you genuinely need more capacity, add a second small instance rather than a bigger one; horizontal scaling keeps your failure domain small and your deploys boring. Before you price anything, work through calculating cost per API request with your own numbers — the framework is fast enough that your margin is decided by egress, vendors, and database sizing, not by Python.

FAQ

How much does a FastAPI service cost to run at 1 million requests a month? Between $12 and $25 all in, and compute is the smallest line. A single $12 two-vCPU container handles 1M requests using roughly 1% of its capacity even with a database round trip per call. Add $7 to $15 for managed Postgres and $1 to $2 of egress once GZip is on. The number that moves is third-party API spend: if each request calls a vendor at $0.001, that is $1,000 a month and your infrastructure is a rounding error. Price your tiers against vendor cost, not server cost.

When should I add workers instead of a second instance? Add workers only while worker count is below vCPU count — past that you buy memory, not throughput, as the chart above shows. Once you are at one worker per vCPU and still saturating, add a second instance behind the load balancer. Two small instances also survive a deploy or a crash without dropping traffic, which one bigger box cannot do. The exception is a CPU-bound workload such as image processing, where you should move the work to a background queue rather than scaling the web tier at all.

What is the migration risk if I start on FastAPI and outgrow it? Low, and lower than the alternatives. Your handlers are plain async functions and your schemas are Pydantic models, both of which port to Litestar or Starlette with mechanical edits — see FastAPI vs Litestar for where the seams are. The genuinely sticky parts are your OpenAPI contract and your URL structure, and those belong to you rather than the framework. If you are still deciding, FastAPI vs Django REST Framework covers the case where an admin and ORM matter more than raw speed.

How do I rotate an API key without breaking live customers? Accept two valid keys per customer at once. Store a key set rather than a single hash, let the customer generate the new key, give them a window to switch, then revoke the old one and log the last use time so you know it is safe. Because authentication lives in a dependency here, this is a change in one file rather than twenty. The full procedure is in rotating API keys without downtime.

Should I expose the interactive docs to paying customers? Expose a documentation page, not the live Swagger UI on your production host. The interactive UI encourages customers to fire real writes against real data with real keys, and it broadcasts every unreleased endpoint you have merged. Serve the OpenAPI JSON from a versioned static path and render it on a separate docs site instead — ReDoc vs Swagger UI covers which renderer converts better.

Same area:

Other areas: