FastAPI: Building High-Performance Python APIs
You write a Flask endpoint, then spend the next twenty lines pulling fields out of request.json, checking types, returning 400s by hand, and updating a Swagger file that's already out of date. Then a frontend dev asks why price came back as a string. Most of that work is the same in every endpoint, and it's where the bugs live.
FastAPI moves that work into Python type hints. You declare what a request should look like, and the framework parses it, validates it, serializes the response, and generates the OpenAPI docs from the same declaration. This post covers project setup, routing, Pydantic schemas, dependency injection for DB sessions and JWT auth, testing with dependency overrides, and a Docker setup, plus the places where FastAPI isn't the right pick.
FastAPI runs on Starlette (ASGI) and Pydantic v2, whose core is written in Rust. For I/O-bound APIs that means it handles concurrent requests far better than a WSGI app like Flask, because one worker can serve many requests while they wait on the database or network. For CPU-heavy endpoints the gap mostly disappears; Python is still Python.
Why Type Hints Change How You Write APIs
Older Python frameworks treat types as optional decoration. FastAPI was built around them from the start (Python 3.8+), so the annotations on your function actually drive behavior. That removes a lot of boilerplate:
- Validation: Request bodies, query params, and path params are checked against your type hints. No manual parsing.
- Serialization: Return values are converted to JSON based on Pydantic models.
- Interactive docs: Swagger UI and ReDoc are served at
/docsand/redocwith no configuration. - Editor autocomplete: Request data is typed, so your editor knows what's on it.
- Async support: Routes can be
async def, so I/O doesn't block other requests.
Here's how it compares with the other two common choices:
| Feature | FastAPI | Flask | Django REST Framework |
|---|---|---|---|
| Performance (async) | Very High (ASGI/Starlette) | Medium (WSGI) | Medium (WSGI) |
| Auto API Docs | Built-in Swagger + ReDoc | No (needs extensions) | Partial (Browsable API) |
| Data Validation | Pydantic (type-based, built-in) | Manual or marshmallow | Serializers (verbose) |
| Type Safety | Native Python type hints | Optional/manual | Partial |
| Dependency Injection | Built-in (Depends) |
Manual patterns | Limited |
| Learning Curve | Low-Medium | Low | Medium-High |
Installation and Project Setup
You need two packages: fastapi itself and an ASGI server to run it. Most people use uvicorn for both development and production.
Install FastAPI and Uvicorn
The [standard] extra for uvicorn pulls in uvloop and httptools, which are faster than the pure Python defaults.
# Core installation
pip install fastapi uvicorn[standard]
# Database support (async PostgreSQL)
pip install sqlalchemy[asyncio] asyncpg
# JWT authentication
pip install python-jose[cryptography] passlib[bcrypt]
# Environment variables
pip install python-dotenv
Organize Your Project Structure
Separate concerns into routers, models, schemas, and dependencies. A single main.py is fine for a prototype, but it gets painful after a dozen endpoints.
my-api/
├── app/
│ ├── main.py # FastAPI app instance and middleware
│ ├── config.py # Settings loaded from .env
│ ├── database.py # Async engine and session factory
│ ├── models/ # SQLAlchemy ORM table definitions
│ │ └── user.py
│ ├── schemas/ # Pydantic request/response schemas
│ │ └── user.py
│ ├── routers/ # Route handlers grouped by domain
│ │ ├── users.py
│ │ └── items.py
│ └── dependencies/ # Shared deps: auth, db session
│ └── auth.py
├── tests/
│ └── test_users.py
├── requirements.txt
└── .env
Create the Application Entry Point
Configure the FastAPI() instance with a title, description, and version. These show up at the top of the generated docs.
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers import users, items
app = FastAPI(
title="My API",
description="A high-performance REST API built with FastAPI",
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://yourdomain.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
app.include_router(items.router, prefix="/api/v1/items", tags=["items"])
@app.get("/health", tags=["health"])
async def health_check():
return {"status": "healthy", "version": "1.0.0"}
Start the Development Server
Visit http://localhost:8000/docs once it's running and you'll get interactive docs where you can send real requests. Note that --reload is for development only; it watches files and restarts the process.
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
Building Routes and Path Operations
FastAPI's docs call routes path operations: a function with an HTTP method decorator on top. The framework reads the function's type annotations to decide how to parse the request and serialize the response. In practice, the type hints are the API contract.
When a request doesn't match those hints, FastAPI returns a 422 Unprocessable Entity with a detail array listing each failing field and its location (["body", "price"], ["query", "limit"]). Frontend devs often expect a 400 here, so it's worth agreeing on that early or adding a custom handler for RequestValidationError.
FastAPI request lifecycle, from HTTP request to validated JSON response
Define routes using HTTP method decorators. FastAPI infers parameter sources from the function signature:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root():
return {"message": "Hello World"}
@app.get("/items/{item_id}")
async def get_item(item_id: int):
return {"item_id": item_id}
@app.post("/items", status_code=201)
async def create_item(name: str, price: float):
return {"name": name, "price": price}
@app.delete("/items/{item_id}", status_code=204)
async def delete_item(item_id: int):
return None
Parameters in the path template become path params; others become query params automatically:
from fastapi import FastAPI, Query, Path
from typing import Optional
app = FastAPI()
# Path parameter with built-in validation
@app.get("/users/{user_id}")
async def get_user(
user_id: int = Path(..., gt=0, description="Must be positive")
):
return {"user_id": user_id}
# Query parameters with defaults and constraints
@app.get("/items")
async def list_items(
skip: int = Query(0, ge=0),
limit: int = Query(10, le=100),
search: Optional[str] = Query(None, min_length=3)
):
return {"skip": skip, "limit": limit, "search": search}
# Enum parameter: FastAPI validates the value is one of the choices
from enum import Enum
class SortOrder(str, Enum):
asc = "asc"
desc = "desc"
@app.get("/posts")
async def list_posts(order: SortOrder = SortOrder.desc):
return {"order": order}
A complete CRUD router for a Post resource using APIRouter:
from fastapi import APIRouter, HTTPException
from typing import List
from app.schemas.post import PostCreate, PostResponse, PostUpdate
router = APIRouter()
@router.get("/", response_model=List[PostResponse])
async def list_posts(skip: int = 0, limit: int = 10):
return await post_service.get_all(skip=skip, limit=limit)
@router.post("/", response_model=PostResponse, status_code=201)
async def create_post(post: PostCreate):
return await post_service.create(post)
@router.get("/{post_id}", response_model=PostResponse)
async def get_post(post_id: int):
post = await post_service.get_by_id(post_id)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
return post
@router.put("/{post_id}", response_model=PostResponse)
async def update_post(post_id: int, data: PostUpdate):
post = await post_service.update(post_id, data)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
return post
@router.delete("/{post_id}", status_code=204)
async def delete_post(post_id: int):
deleted = await post_service.delete(post_id)
if not deleted:
raise HTTPException(status_code=404, detail="Post not found")
Async routes free up the event loop while they wait on I/O, so one process can juggle many requests. The catch: calling a blocking library (requests, time.sleep, a sync DB driver) inside async def stalls every request on that worker. If you can't avoid a blocking call, make the route a plain def instead.
import asyncio
import httpx
from fastapi import FastAPI
app = FastAPI()
# Non-blocking HTTP call
@app.get("/weather/{city}")
async def get_weather(city: str):
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.weather.example.com/{city}"
)
return response.json()
# Parallel async operations with gather
@app.get("/dashboard/{user_id}")
async def get_dashboard(user_id: int):
stats, orders, notifications = await asyncio.gather(
get_user_stats(user_id),
get_recent_orders(user_id),
get_notifications(user_id)
)
return {"stats": stats, "orders": orders, "notifications": notifications}
# Blocking work: use plain def, and FastAPI runs it in a thread pool
@app.get("/compute")
def heavy_computation(n: int):
return {"result": sum(i * i for i in range(n))}
Pydantic Models: Type-Safe Data Validation
Every request body and response in FastAPI goes through Pydantic. You describe the shape of your data as a class inheriting from BaseModel, and Pydantic does the type coercion, validation, nesting, and JSON output. Current FastAPI uses Pydantic v2, whose core was rewritten in Rust and is a lot faster than v1. If you're upgrading an old project, watch for renamed methods: .dict() is now .model_dump() and orm_mode became from_attributes.
Separating Request and Response Schemas
Don't return your ORM model straight from a route, or sooner or later a password hash ends up in a JSON response. Separate schemas for input and output make that hard to do by accident:
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
from typing import Optional
from datetime import datetime
class UserBase(BaseModel):
email: EmailStr
full_name: str
is_active: bool = True
# CREATE: includes password field
class UserCreate(UserBase):
password: str
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if not any(c.isdigit() for c in v):
raise ValueError("Password must contain at least one digit")
return v
# UPDATE: all fields optional for PATCH semantics
class UserUpdate(BaseModel):
full_name: Optional[str] = None
email: Optional[EmailStr] = None
is_active: Optional[bool] = None
# RESPONSE: never expose password_hash
class UserResponse(UserBase):
id: int
created_at: datetime
# Allows reading from SQLAlchemy ORM objects directly
model_config = ConfigDict(from_attributes=True)
Advanced Validators and Nested Models
from pydantic import BaseModel, Field, model_validator
from typing import Literal
from decimal import Decimal
class OrderCreate(BaseModel):
product_id: int = Field(..., gt=0)
quantity: int = Field(..., ge=1, le=1000)
unit_price: Decimal = Field(..., gt=0, decimal_places=2)
discount_pct: float = Field(0.0, ge=0.0, le=100.0)
shipping: Literal["standard", "express", "overnight"] = "standard"
@model_validator(mode="after")
def check_overnight_minimum(self) -> "OrderCreate":
total = float(self.unit_price) * self.quantity
discounted = total * (1 - self.discount_pct / 100)
if self.shipping == "overnight" and discounted < 50:
raise ValueError("Overnight shipping requires a $50+ order total")
return self
# Nested models work out of the box
class AddressSchema(BaseModel):
street: str
city: str
country: str = "US"
class UserWithAddress(UserResponse):
address: Optional[AddressSchema] = None
orders: list[OrderCreate] = []
Dependency Injection and Authentication
This is the part of FastAPI I'd miss most if I went back to Flask. You pass any callable to Depends(), FastAPI calls it before your handler runs, and the result arrives as a parameter. Database sessions, auth checks, and rate limits all live outside the route body, and you can swap them out in tests.
Database Session as a Dependency
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from typing import AsyncGenerator
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/mydb"
engine = create_async_engine(DATABASE_URL)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
JWT Bearer Token Authentication
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from app.dependencies.database import get_db
SECRET_KEY = "your-secret-key" # Load from environment in production
ALGORITHM = "HS256"
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
) -> User:
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: int = payload.get("sub")
if user_id is None:
raise credentials_error
except JWTError:
raise credentials_error
user = await db.get(User, user_id)
if user is None or not user.is_active:
raise credentials_error
return user
# Composable: build more specific deps on top of get_current_user
async def require_admin(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
return current_user
Using them in a route is one parameter each. FastAPI resolves the whole chain, and within a single request a shared dependency like get_db is only called once and its result reused:
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter()
# Any authenticated user
@router.get("/profile")
async def get_profile(current_user: User = Depends(get_current_user)):
return {"id": current_user.id, "email": current_user.email}
# Admin-only endpoint
@router.get("/admin/users")
async def list_all_users(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(User))
return result.scalars().all()
# Multiple dependencies compose naturally
@router.post("/posts", status_code=201)
async def create_post(
data: PostCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
post = Post(**data.model_dump(), author_id=current_user.id)
db.add(post)
await db.flush()
return post
Don't hardcode SECRET_KEY. Load it from environment variables via python-dotenv or a secrets manager. Set a short exp claim on JWT tokens, rotate refresh tokens, and consider disabling the auto-generated /docs endpoint in production with docs_url=None, redoc_url=None.
Testing and Deployment
TestClient sends real HTTP requests to your app without starting a server. Combined with dependency_overrides, you can point every route at a test database by changing one dictionary entry. One gotcha with in-memory SQLite: each new connection gets its own empty database, so if your tables seem to vanish between requests, use a file-based SQLite or a StaticPool.
Writing Tests with TestClient
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from app.main import app
from app.dependencies.database import get_db
# Override DB with in-memory SQLite for tests
TEST_URL = "sqlite+aiosqlite:///:memory:"
test_engine = create_async_engine(TEST_URL)
TestSession = async_sessionmaker(test_engine)
async def override_get_db():
async with TestSession() as session:
yield session
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
def test_health_check():
response = client.get("/health")
assert response.status_code == 200
assert response.json()["status"] == "healthy"
def test_create_user():
response = client.post("/api/v1/users/", json={
"email": "test@example.com",
"full_name": "Test User",
"password": "securePass1"
})
assert response.status_code == 201
data = response.json()
assert data["email"] == "test@example.com"
assert "password" not in data # Schema never exposes password_hash
def test_unauthorized_profile():
response = client.get("/api/v1/users/profile")
assert response.status_code == 401
Dockerizing for Production
FROM python:3.12-slim
WORKDIR /app
# Dependencies layer cached separately from source
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# 4 workers: rule of thumb is 2 * CPU_COUNT + 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
version: "3.9"
services:
api:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql+asyncpg://user:pass@db/mydb
- SECRET_KEY=${SECRET_KEY}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: mydb
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
interval: 5s
retries: 5
volumes:
postgres_data:
FastAPI Production Checklist
| Category | Setting | Recommendation |
|---|---|---|
| Server | ASGI runner | Gunicorn + UvicornWorker for process management |
| Security | API docs | Set docs_url=None, redoc_url=None in production |
| Security | CORS origins | Never use allow_origins=["*"] with credentials |
| Performance | Workers | 2 × CPU_COUNT + 1 uvicorn workers |
| Database | Connection pool | Set pool_size and max_overflow on the engine |
| Observability | Logging | Structured JSON logs with request correlation IDs |
When I'd use FastAPI (and when I wouldn't)
- Good fit: JSON APIs consumed by a separate frontend or mobile app, internal microservices, and anything that serves an ML model. You get typed validation and an OpenAPI spec you can generate clients from.
- Think twice: if you need an admin panel, auth, migrations, and server-rendered pages out of the box, Django gives you all of that today. With FastAPI you assemble it yourself from SQLAlchemy, Alembic, and a handful of other libraries.
- Async isn't free: if your database driver or SDKs are sync only, you'll either run everything as plain
defor fight blocking calls insideasync def. Check your dependencies before picking FastAPI for the speed. - CPU-heavy work: image processing or big report generation shouldn't run in a request at all. Push it to a task queue like Celery or RQ, whatever framework you use.
If you're already comfortable with Flask and your API is small and sync, there's no urgent reason to switch. For a new JSON API where the request shapes matter, FastAPI is the first thing I'd reach for in Python, mostly because the validation and docs stop drifting away from the code.