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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
| Variable | Default | Production setting |
|---|---|---|
API_ENV | development | production — also hides /docs |
DATABASE_URL | local SQLite | async driver URL from the platform |
UPSTREAM_TIMEOUT | 5.0 | 3.0 if you retry, 10.0 if not |
POOL_MAX_CONNECTIONS | 20 | server limit ÷ worker count, minus 5 |
WORKER_COUNT | 2 | one per vCPU |
LOG_LEVEL | warning | warning; info only while debugging |
GRACEFUL_TIMEOUT | 20 | above 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.
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.
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.
Related
Same area:
- FastAPI vs Flask for API Development — the async and ecosystem comparison behind the recommendation here.
- Uvicorn vs Gunicorn Worker Configuration — the worker model that sets your real concurrency ceiling.
- Documenting APIs with OpenAPI — turn the schema this setup generates into a contract customers can build against.
- Handling API Authentication in Python — add the dependency that guards every route before you have twenty of them.
Other areas:
- Containerizing Python APIs with Docker — package this layout into an image that builds in seconds.
- Tracking API Usage and Analytics — the middleware that turns requests into billable events.