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

bash
pip install fastapi
pip install "uvicorn[standard]"   # ASGI server to run it

Output: (none — exits 0 on success)

Quick example

python
# 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}
bash
uvicorn main:app --reload
curl -s "http://127.0.0.1:8000/items/42?q=search"

Output:

text
{"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 uvicorn or hypercorn, or use gunicorn with the uvicorn.workers.UvicornWorker class: 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 def route, it blocks the entire server. Either use async database drivers or declare the route as def (FastAPI runs sync routes in a thread pool automatically).

Declare your route as def (not async 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

python
# 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]
bash
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:

text
[{"id":1,"name":"Widget"},{"id":2,"name":"Gadget"}]
{"name":"Widget"}
{"detail":"Item not found"}
{"id":3,"name":"Thingamajig"}

Async example — non-blocking route

python
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.

python
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.).

python
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 / UserPatch models per route. One mega-model with everything Optional is the single biggest mistake teams make migrating from Flask — it breaks required validation 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.

python
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.

python
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 Depends expressions in a typing.Annotated alias (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.

python
# 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_KEY from 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.

python
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.

python
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).

python
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).

python
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.

python
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).

python
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.

python
# 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.

python
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.

python
# 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

python
# 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

python
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

python
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.

bash
# 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:

text
[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
# 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-headers so request.client.host and request.url.scheme reflect the real client, not the proxy.

Quick reference

TaskCode
Create appapp = FastAPI(title="...")
Route@app.get("/path") / @app.post(...)
Path paramdef fn(item_id: int = Path(ge=1))
Query paramdef fn(q: str = Query(min_length=1))
Body (JSON)def fn(item: Item) where Item(BaseModel)
Form datadef fn(f: Annotated[str, Form()])
File uploaddef fn(file: UploadFile = File(...))
Headerdef fn(h: Annotated[str, Header()])
Dependencydef fn(db: Annotated[Session, Depends(get_db)])
Yield depdef get_db(): db=...; try: yield db; finally: db.close()
Background taskdef fn(t: BackgroundTasks): t.add_task(send, x)
Lifespan@asynccontextmanager async def lifespan(app): ...; yield; ...
Routerr = APIRouter(prefix="/users", tags=["users"])
Mount routerapp.include_router(r)
Middlewareapp.add_middleware(CORSMiddleware, ...)
Exception handler@app.exception_handler(MyExc) def fn(req, exc):
Raise HTTP errorraise HTTPException(404, "Not found")
Stream responsereturn StreamingResponse(gen(), media_type="...")
Test clientclient = TestClient(app)
Override depapp.dependency_overrides[get_db] = fake_db
Run devuvicorn main:app --reload
Run prodgunicorn main:app -k uvicorn.workers.UvicornWorker -w 4