cheat sheet
fastapi
Build high-performance async REST APIs with FastAPI. Covers path params, request bodies, Pydantic models, dependency injection, and auto-generated OpenAPI docs.
fastapi — Fast ASGI API Framework
What it is
FastAPI is a modern ASGI web framework that uses Python type hints to:
- Validate and parse request data (via Pydantic).
- Auto-generate OpenAPI (Swagger UI) and JSON Schema docs.
- Handle async/await natively.
It's consistently the fastest Python web framework in benchmarks and has become the default choice for new Python APIs.
Install
pip install fastapi
pip install "uvicorn[standard]" # ASGI server to run it
Output: (none — exits 0 on success)
Quick example
# main.py
from fastapi import FastAPI
app = FastAPI()
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
uvicorn main:app --reload
curl -s "http://127.0.0.1:8000/items/42?q=search"
Output:
{"item_id":42,"q":"search"}
Browse to http://127.0.0.1:8000/docs for the interactive Swagger UI — automatically generated from your type hints.
When / why to use it
- New async APIs that benefit from non-blocking I/O (database calls, external HTTP).
- When you want OpenAPI docs with zero extra effort.
- Projects using Pydantic for validation — FastAPI integrates it natively.
Prefer Flask for simple sync-only apps with an existing WSGI ecosystem. Prefer Django when you need admin, auth, and ORM batteries.
Common pitfalls
FastAPI is ASGI — it needs an ASGI server — do not run it with gunicorn alone. Use
uvicornorhypercorn, or usegunicornwith theuvicorn.workers.UvicornWorkerclass:gunicorn main:app -k uvicorn.workers.UvicornWorker.
Sync functions in async routes block the event loop — if you call a blocking I/O function (e.g. a synchronous SQLAlchemy query) inside an
async defroute, it blocks the entire server. Either useasyncdatabase drivers or declare the route asdef(FastAPI runs sync routes in a thread pool automatically).
Declare your route as
def(notasync def) when the body is synchronous — FastAPI will run it in a thread pool, keeping the event loop free.
Richer example — request body, dependency injection, and error handling
# main.py
from fastapi import FastAPI, Depends, HTTPException, status
from pydantic import BaseModel
app = FastAPI(title="Item Store", version="1.0")
ITEMS: dict[int, str] = {1: "Widget", 2: "Gadget"}
class NewItem(BaseModel):
name: str
def get_item_or_404(item_id: int) -> str:
"""Dependency: resolves an item by ID or raises 404."""
item = ITEMS.get(item_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="Item not found")
return item
@app.get("/items")
def list_items() -> list[dict]:
return [{"id": k, "name": v} for k, v in ITEMS.items()]
@app.get("/items/{item_id}")
def read_item(name: str = Depends(get_item_or_404)) -> dict:
return {"name": name}
@app.post("/items", status_code=status.HTTP_201_CREATED)
def create_item(item: NewItem) -> dict:
new_id = max(ITEMS) + 1
ITEMS[new_id] = item.name
return {"id": new_id, "name": item.name}
@app.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(item_id: int):
if item_id not in ITEMS:
raise HTTPException(status_code=404, detail="Item not found")
del ITEMS[item_id]
curl -s http://127.0.0.1:8000/items
curl -s http://127.0.0.1:8000/items/1
curl -s http://127.0.0.1:8000/items/99
curl -s -X POST http://127.0.0.1:8000/items \
-H "Content-Type: application/json" \
-d '{"name":"Thingamajig"}'
Output:
[{"id":1,"name":"Widget"},{"id":2,"name":"Gadget"}]
{"name":"Widget"}
{"detail":"Item not found"}
{"id":3,"name":"Thingamajig"}
Async example — non-blocking route
import asyncio
import httpx
from fastapi import FastAPI
app = FastAPI()
@app.get("/proxy")
async def proxy():
async with httpx.AsyncClient() as client:
r = await client.get("https://httpbin.org/get")
return r.json()["headers"]
Path, query, and body parameters
FastAPI decides whether a parameter comes from the path, query string, or body by inspecting the function signature: anything that matches a {name} in the route is a path parameter, simple types (int, str, bool) default to query parameters, and Pydantic models default to the JSON body. Use Path, Query, Body, Header, Cookie, and Form to override the default location and attach validation metadata.
from fastapi import FastAPI, Query, Path, Body, Header, Cookie
from pydantic import BaseModel
from typing import Annotated
app = FastAPI()
@app.get("/users/{user_id}/posts/{post_id}")
def get_post(
user_id: int = Path(ge=1), # path param, must be >= 1
post_id: int = Path(ge=1),
include_draft: bool = Query(False), # query param with default
):
return {"user_id": user_id, "post_id": post_id, "draft": include_draft}
# Repeated query params → list
@app.get("/search")
def search(
q: Annotated[str, Query(min_length=2, max_length=80, examples=["fastapi"])],
tags: Annotated[list[str], Query()] = [], # ?tags=a&tags=b
page: Annotated[int, Query(ge=1, le=1000)] = 1,
):
return {"q": q, "tags": tags, "page": page}
# Multiple body parameters — FastAPI builds a JSON object with named keys
class Item(BaseModel):
name: str
price: float
@app.put("/items/{item_id}")
def upsert(item_id: int, item: Item, owner: Annotated[str, Body()] = "system"):
# Body: {"item": {"name": "...", "price": 1.0}, "owner": "alice"}
return {"item_id": item_id, **item.model_dump(), "owner": owner}
# Header / Cookie params
@app.get("/whoami")
def whoami(
user_agent: Annotated[str | None, Header()] = None, # User-Agent
session_id: Annotated[str | None, Cookie()] = None,
):
return {"ua": user_agent, "session": session_id}
Response models and status codes
response_model declares the outward schema for a route. FastAPI validates and serializes the return value against it, strips fields not in the model (great for hiding password_hash), and uses the model in the OpenAPI schema. Pair it with status_code for standard HTTP semantics (201 for create, 204 for delete, etc.).
from fastapi import FastAPI, status
from pydantic import BaseModel, Field, EmailStr
app = FastAPI()
class UserIn(BaseModel):
email: EmailStr
password: str = Field(min_length=8)
class UserOut(BaseModel):
id: int
email: EmailStr
# No password fields — FastAPI strips them from the response
@app.post(
"/users",
response_model=UserOut,
status_code=status.HTTP_201_CREATED,
responses={409: {"description": "Email already exists"}},
)
def create_user(user_in: UserIn) -> UserOut:
# Even if we return a UserIn-shaped dict with the password, the
# response_model strips fields that aren't declared on UserOut.
return {"id": 1, "email": user_in.email, "password": user_in.password}
# response_model_exclude_none — drop null fields from the JSON
@app.get("/users/{uid}", response_model=UserOut, response_model_exclude_none=True)
def get_user(uid: int) -> UserOut:
return UserOut(id=uid, email="alice@example.com")
Prefer separate
UserIn/UserOut/UserPatchmodels per route. One mega-model with everythingOptionalis the single biggest mistake teams make migrating from Flask — it breaksrequiredvalidation and OpenAPI docs.
Async vs sync routes
FastAPI happily runs both async def and plain def route handlers. The rule is: if your body awaits I/O, use async def and await it; if your body calls blocking I/O (a sync ORM, requests, time.sleep), declare the route def and let FastAPI run it in a thread pool. Mixing them is fine; what's not fine is calling a blocking function from inside async def — that stalls the entire event loop.
import time
import httpx
from fastapi import FastAPI
app = FastAPI()
# Good — async + awaitable I/O
@app.get("/proxy")
async def proxy():
async with httpx.AsyncClient() as client:
r = await client.get("https://httpbin.org/get")
return r.json()
# Good — sync def, FastAPI runs it in a threadpool
@app.get("/legacy")
def legacy():
time.sleep(1) # blocking, but the loop stays free
return {"ok": True}
# BAD — blocking call inside async def stalls every other request
@app.get("/bad")
async def bad():
time.sleep(1) # blocks the loop for 1 full second
return {"ok": False}
# Fix it: offload to a thread
import anyio
@app.get("/fixed")
async def fixed():
await anyio.to_thread.run_sync(time.sleep, 1)
return {"ok": True}
Dependency injection
Dependencies in FastAPI are just functions or callables; FastAPI inspects their signatures, resolves their parameters (themselves dependencies, path params, query params, etc.) and passes the result into the route. They are the canonical place to put database sessions, current-user lookup, pagination parameters, and feature-flag gates. Dependencies can be yielded to provide setup/teardown (like with blocks), and can be applied app-wide, router-wide, or per-route.
from fastapi import FastAPI, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Annotated
app = FastAPI()
# A "yield" dependency — setup, then teardown after the response is sent
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
DBSession = Annotated[Session, Depends(get_db)]
# Reusable pagination
def pagination(page: int = 1, page_size: int = 20) -> dict:
return {"offset": (page - 1) * page_size, "limit": page_size}
Pagination = Annotated[dict, Depends(pagination)]
@app.get("/users")
def list_users(db: DBSession, page: Pagination):
return db.query(User).offset(page["offset"]).limit(page["limit"]).all()
# Class-based dependency — useful when you need shared config
class CommonHeaders:
def __init__(self, x_request_id: Annotated[str | None, Header()] = None):
self.request_id = x_request_id
@app.get("/items")
def list_items(headers: Annotated[CommonHeaders, Depends()]):
return {"request_id": headers.request_id}
# Sub-dependency — get_current_user depends on get_token
def get_token(authorization: Annotated[str | None, Header()] = None) -> str:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
return authorization.removeprefix("Bearer ")
def get_current_user(token: Annotated[str, Depends(get_token)]) -> User:
user = decode_jwt(token)
if not user:
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
Wrap common
Dependsexpressions in atyping.Annotatedalias (e.g.DBSession,CurrentUser). It removes boilerplate, makes route signatures readable, and lets mypy resolve the actual type.
Security — OAuth2 + JWT
FastAPI's fastapi.security module ships ready-made dependencies for HTTP Basic, API keys (header / query / cookie), OAuth2 password flow, and OAuth2 with bearer tokens. The common pattern for first-party APIs is OAuth2 Password flow + JWT bearer tokens: clients post username/password to /token, get back a signed JWT, and pass it as Authorization: Bearer <jwt> on subsequent calls.
# pip install "python-jose[cryptography]" passlib[bcrypt]
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import jwt, JWTError
from passlib.context import CryptContext
from pydantic import BaseModel
from typing import Annotated
SECRET_KEY = os.environ["JWT_SECRET"]
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenUser(BaseModel):
sub: str
exp: datetime
def authenticate(username: str, password: str) -> User | None:
user = USERS.get(username)
if user and pwd_ctx.verify(password, user["password_hash"]):
return user
return None
def create_access_token(sub: str) -> str:
payload = {"sub": sub, "exp": datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
@app.post("/token", response_model=Token)
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
user = authenticate(form.username, form.password)
if not user:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Bad credentials",
headers={"WWW-Authenticate": "Bearer"})
return Token(access_token=create_access_token(user["username"]))
def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token")
user = USERS.get(payload.get("sub"))
if not user:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found")
return user
@app.get("/me")
def me(user: Annotated[User, Depends(get_current_user)]):
return {"username": user["username"]}
Never sign JWTs with a hardcoded secret in source. Load
SECRET_KEYfrom the environment, rotate it on compromise, and use asymmetric keys (RS256/ES256) when token verification happens outside your trust boundary.
CORS and other middleware
Middleware wraps the ASGI app and can inspect or modify every request/response — adding CORS headers, GZipping responses, enforcing trusted hosts, or logging. Middleware order is the order you add_middleware(...); outer middleware sees the request first and the response last.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
import time, logging
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
expose_headers=["X-Request-Id"],
max_age=600,
)
app.add_middleware(GZipMiddleware, minimum_size=1024)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=["api.example.com", "*.example.com"])
# Custom timing middleware via BaseHTTPMiddleware
class TimingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
start = time.perf_counter()
response = await call_next(request)
elapsed_ms = (time.perf_counter() - start) * 1000
response.headers["X-Response-Time-Ms"] = f"{elapsed_ms:.1f}"
logging.info("%s %s -> %d %.1fms", request.method, request.url.path,
response.status_code, elapsed_ms)
return response
app.add_middleware(TimingMiddleware)
Exception handling
Raise HTTPException from anywhere (route, dependency, validator) to return a structured error. For domain exceptions you don't want to leak as HTTP details, register a global handler with @app.exception_handler(...) and translate them to JSON responses. FastAPI also lets you override the default RequestValidationError handler to customise 422 responses.
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
app = FastAPI()
class NotFoundError(Exception):
def __init__(self, what: str):
self.what = what
@app.exception_handler(NotFoundError)
async def not_found(request: Request, exc: NotFoundError) -> JSONResponse:
return JSONResponse(status_code=404, content={"detail": f"{exc.what} not found"})
@app.exception_handler(RequestValidationError)
async def validation_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={"detail": "Validation failed", "errors": exc.errors()},
)
# Re-raise as HTTPException when you want the default error envelope
@app.get("/users/{uid}")
def read_user(uid: int):
if uid not in USERS:
raise HTTPException(404, detail="User not found", headers={"X-Hint": "list-users"})
return USERS[uid]
Background tasks
BackgroundTasks runs short, fire-and-forget functions after the response is sent — perfect for sending a transactional email or writing an audit log without making the client wait. For anything longer than a few seconds, anything that needs retries, or anything that must survive a process restart, use a real task queue (Celery, RQ, Arq, Dramatiq).
from fastapi import FastAPI, BackgroundTasks
from pydantic import BaseModel, EmailStr
app = FastAPI()
class Signup(BaseModel):
email: EmailStr
def send_welcome_email(to: str):
# Network call — happens after the 201 has been returned
smtp.send(to, "Welcome", "Glad to have you!")
@app.post("/signup", status_code=201)
def signup(body: Signup, tasks: BackgroundTasks):
user = create_user(body.email)
tasks.add_task(send_welcome_email, user.email)
return {"id": user.id}
Background tasks run in the same process as the request. If the worker is killed before the task finishes, the work is lost. Use Celery/RQ/Arq for at-least-once delivery.
File uploads
UploadFile is a SpooledTemporaryFile wrapper — the file lives in memory until it grows past a threshold, then spills to disk. Use File() for required uploads, set MAX_CONTENT_LENGTH via your server, and validate MIME types by sniffing magic bytes (not headers).
from fastapi import FastAPI, UploadFile, File, HTTPException
app = FastAPI()
ALLOWED = {"image/png", "image/jpeg"}
MAX_BYTES = 5 * 1024 * 1024
@app.post("/upload")
async def upload(file: UploadFile = File(...)):
if file.content_type not in ALLOWED:
raise HTTPException(415, "Unsupported file type")
# Stream to disk in chunks; do not call file.read() on huge files
size = 0
with open(f"/tmp/{file.filename}", "wb") as out:
while chunk := await file.read(1024 * 1024):
size += len(chunk)
if size > MAX_BYTES:
raise HTTPException(413, "File too large")
out.write(chunk)
return {"name": file.filename, "size": size}
# Multiple files + form fields together
@app.post("/album")
async def upload_album(
title: Annotated[str, Form()],
photos: list[UploadFile] = File(...),
):
return {"title": title, "count": len(photos)}
Streaming responses and SSE
For payloads that are too big to fit in memory or for events that arrive over time, return a StreamingResponse wrapping an async generator. Set media_type="text/event-stream" to push Server-Sent Events to the browser.
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio, json
app = FastAPI()
@app.get("/download/report.csv")
def csv():
def rows():
yield "id,name\n"
for i in range(1_000_000):
yield f"{i},user-{i}\n"
return StreamingResponse(rows(), media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=report.csv"})
@app.get("/events")
async def events():
async def event_stream():
for i in range(10):
yield f"data: {json.dumps({'tick': i})}\n\n"
await asyncio.sleep(1)
return StreamingResponse(event_stream(), media_type="text/event-stream")
Lifespan handlers — startup and shutdown
A lifespan handler is an async context manager that runs once when the app starts and once when it shuts down. It's the canonical place to open and close shared resources: database engines, Redis pools, HTTP clients, background scheduler. Prefer it over the older @app.on_event("startup") / @app.on_event("shutdown") decorators (deprecated in recent Starlette versions).
from contextlib import asynccontextmanager
from fastapi import FastAPI
import httpx
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
app.state.http = httpx.AsyncClient(timeout=10)
app.state.db = await create_async_engine(os.environ["DATABASE_URL"])
yield
# Shutdown
await app.state.http.aclose()
await app.state.db.dispose()
app = FastAPI(lifespan=lifespan)
@app.get("/proxy")
async def proxy(request: Request):
r = await request.app.state.http.get("https://httpbin.org/get")
return r.json()
Routers — modular APIs
APIRouter is FastAPI's equivalent of Flask's blueprint: it groups related routes, dependencies, and tags into a self-contained module. Routers are mounted with app.include_router(...) and can be nested.
# myapp/routers/users.py
from fastapi import APIRouter, Depends
router = APIRouter(
prefix="/users",
tags=["users"],
dependencies=[Depends(get_current_user)],
responses={401: {"description": "Unauthorized"}},
)
@router.get("/")
def list_users(): ...
@router.get("/{uid}")
def get_user(uid: int): ...
# main.py
from fastapi import FastAPI
from myapp.routers import users, items
app = FastAPI()
app.include_router(users.router, prefix="/api/v1")
app.include_router(items.router, prefix="/api/v1")
OpenAPI customisation
FastAPI auto-generates an OpenAPI 3.1 schema from your code. Customise the title, description, version, contact, and license via FastAPI(...); tweak per-route docs with summary, description, response_description, tags, and responses. To override the whole schema, monkey-patch app.openapi.
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
app = FastAPI(
title="Item Store",
description="REST API for the warehouse",
version="1.2.0",
contact={"name": "Alice Dev", "email": "alice@example.com"},
license_info={"name": "MIT"},
openapi_tags=[
{"name": "users", "description": "User management"},
{"name": "items", "description": "Inventory items"},
],
docs_url="/docs", # set to None to disable Swagger UI in prod
redoc_url="/redoc",
)
@app.get(
"/items/{item_id}",
summary="Fetch a single item",
response_description="The requested item",
tags=["items"],
responses={404: {"description": "Item not found"}},
)
def get_item(item_id: int): ...
# Customising the generated schema
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
schema = get_openapi(title=app.title, version=app.version, routes=app.routes)
schema["info"]["x-logo"] = {"url": "https://example.com/logo.png"}
app.openapi_schema = schema
return schema
app.openapi = custom_openapi
Testing with TestClient
TestClient (backed by httpx) drives the ASGI app in-process — no port, no event loop friction. Combine with pytest fixtures that override dependencies (app.dependency_overrides) to swap database sessions, current-user lookups, and external clients for fakes.
# pip install httpx pytest
import pytest
from fastapi.testclient import TestClient
from main import app, get_db
@pytest.fixture
def client():
# Override the get_db dependency to use an in-memory database
def fake_db():
yield InMemoryDB()
app.dependency_overrides[get_db] = fake_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
def test_create_user(client):
r = client.post("/users", json={"email": "alice@example.com", "password": "supersecret"})
assert r.status_code == 201
assert r.json()["email"] == "alice@example.com"
def test_get_404(client):
r = client.get("/items/999")
assert r.status_code == 404
assert r.json()["detail"] == "Item not found"
# Async tests with httpx.AsyncClient — useful when the route awaits external services
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
@pytest_asyncio.fixture
async def aclient():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.mark.asyncio
async def test_async_endpoint(aclient):
r = await aclient.get("/proxy")
assert r.status_code == 200
Real-world recipes
Database session per request with SQLAlchemy
# db.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
engine = create_engine(os.environ["DATABASE_URL"], pool_pre_ping=True, pool_size=10)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Base = declarative_base()
# deps.py
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# routes.py
@app.post("/items", response_model=ItemOut, status_code=201)
def create_item(item: ItemIn, db: Annotated[Session, Depends(get_db)]):
obj = Item(**item.model_dump())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
Pagination dependency
class Page(BaseModel):
page: int = Field(1, ge=1)
size: int = Field(20, ge=1, le=100)
@property
def offset(self) -> int:
return (self.page - 1) * self.size
def page_params(page: int = 1, size: int = 20) -> Page:
return Page(page=page, size=size)
@app.get("/items")
def list_items(p: Annotated[Page, Depends(page_params)], db: Annotated[Session, Depends(get_db)]):
return db.query(Item).offset(p.offset).limit(p.size).all()
Versioned API with shared dependencies
api_v1 = APIRouter(prefix="/api/v1", dependencies=[Depends(get_current_user)])
api_v2 = APIRouter(prefix="/api/v2", dependencies=[Depends(get_current_user)])
api_v1.include_router(users_v1)
api_v2.include_router(users_v2)
app.include_router(api_v1)
app.include_router(api_v2)
Performance and deployment
A FastAPI deployment is uvicorn workers + a reverse proxy. Use gunicorn with the uvicorn.workers.UvicornWorker class to get gunicorn's process management (graceful reloads, worker recycling) on top of uvicorn's ASGI speed. Right-size workers to the box: 2 × CPU + 1 is the gunicorn default heuristic; raise it when most routes are I/O-bound, lower it when they're CPU-bound.
# Development — auto reload on file change
uvicorn main:app --reload --host 127.0.0.1 --port 8000
# Production — multiple workers under gunicorn
pip install "uvicorn[standard]" gunicorn
gunicorn main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--timeout 60 --graceful-timeout 30 \
--access-logfile - --error-logfile -
# Standalone uvicorn with multiple workers (no gunicorn required)
uvicorn main:app --workers 4 --host 0.0.0.0 --port 8000
# Hypercorn — alternative ASGI server with HTTP/2 + HTTP/3
pip install hypercorn
hypercorn main:app --workers 4 --bind 0.0.0.0:8000
Output:
[2026-05-25 09:14:02 +0000] [12345] [INFO] Running on http://0.0.0.0:8000 (CTRL + C to quit)
[2026-05-25 09:14:02 +0000] [12346] [INFO] Booting worker with pid: 12346
[2026-05-25 09:14:02 +0000] [12347] [INFO] Booting worker with pid: 12347
[2026-05-25 09:14:02 +0000] [12348] [INFO] Booting worker with pid: 12348
[2026-05-25 09:14:02 +0000] [12349] [INFO] Booting worker with pid: 12349
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN pip install --no-cache-dir uvicorn fastapi
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
When running behind a reverse proxy (nginx, Cloudflare), start uvicorn with
--forwarded-allow-ips '*' --proxy-headerssorequest.client.hostandrequest.url.schemereflect the real client, not the proxy.
Quick reference
| Task | Code |
|---|---|
| Create app | app = FastAPI(title="...") |
| Route | @app.get("/path") / @app.post(...) |
| Path param | def fn(item_id: int = Path(ge=1)) |
| Query param | def fn(q: str = Query(min_length=1)) |
| Body (JSON) | def fn(item: Item) where Item(BaseModel) |
| Form data | def fn(f: Annotated[str, Form()]) |
| File upload | def fn(file: UploadFile = File(...)) |
| Header | def fn(h: Annotated[str, Header()]) |
| Dependency | def fn(db: Annotated[Session, Depends(get_db)]) |
| Yield dep | def get_db(): db=...; try: yield db; finally: db.close() |
| Background task | def fn(t: BackgroundTasks): t.add_task(send, x) |
| Lifespan | @asynccontextmanager async def lifespan(app): ...; yield; ... |
| Router | r = APIRouter(prefix="/users", tags=["users"]) |
| Mount router | app.include_router(r) |
| Middleware | app.add_middleware(CORSMiddleware, ...) |
| Exception handler | @app.exception_handler(MyExc) def fn(req, exc): |
| Raise HTTP error | raise HTTPException(404, "Not found") |
| Stream response | return StreamingResponse(gen(), media_type="...") |
| Test client | client = TestClient(app) |
| Override dep | app.dependency_overrides[get_db] = fake_db |
| Run dev | uvicorn main:app --reload |
| Run prod | gunicorn main:app -k uvicorn.workers.UvicornWorker -w 4 |