Learn Python Series):If you've built web APIs in Python before, you've probably used Flask. And Flask is great — I've used it for years. But Flask was designed in 2010, before async/await existed, before type hints existed, before Pydantic existed. It shows its age.
FastAPI, released in 2018 by Sebastián Ramírez, asked a fundamentally different question: "What if we designed a web framework from scratch, using everything modern Python gives us?" The answer turns out to be remarkably elegant. Type hints become automatic validation. Pydantic models become request/response schemas. Async is native. And your API documentation writes itself — literally, generated from your code, always in sync.
Nota bene: FastAPI isn't just "Flask but faster." It's a fundamentally different approach to building APIs — your type annotations are the specification. Write the function signature, and FastAPI generates the validation, serialization, error handling, and OpenAPI docs. Less code, fewer bugs, better docs. If you've followed episode #36 (Type Hints) and episodes #40-41 (Async Python), you already have the foundation. This episode puts those pieces together into something practical.
FastAPI requires two packages: fastapi itself and an ASGI server to run it. The [all] extra installs uvicorn (the ASGI server) and other common dependencies:
pip install "fastapi[all]"
Now create a file called main.py:
from fastapi import FastAPI
app = FastAPI(
title="My First API",
description="A simple API to learn FastAPI basics",
version="0.1.0"
)
@app.get("/")
def read_root():
return {"message": "Hello World"}
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
Run it:
uvicorn main:app --reload
The --reload flag watches for file changes and restarts automatically (development only — don't use in production).
Now visit three URLs:
http://localhost:8000/ — your root endpoint returns JSONhttp://localhost:8000/items/42?q=test — path parameter + query parameterhttp://localhost:8000/docs — interactive Swagger UI documentationThat third URL is the magic. FastAPI generated a complete, interactive API documentation page from your code. You can try out endpoints, see request/response schemas, and explore your API — all without writing a single line of documentation. Visit http://localhost:8000/redoc for a prettier, read-only alternative (ReDoc format).
This is the core insight. In Flask, you write validation code manually:
# Flask approach — manual everything
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route("/users/", methods=["GET"])
def get_user(user_id):
# Manual type conversion
try:
user_id = int(user_id)
except ValueError:
return jsonify({"error": "user_id must be an integer"}), 400
# Manual range validation
if user_id < 1:
return jsonify({"error": "user_id must be positive"}), 400
return jsonify({"user_id": user_id})
In FastAPI, the same thing is just a type annotation:
# FastAPI approach — types are the spec
from fastapi import FastAPI, Path
app = FastAPI()
@app.get("/users/{user_id}")
def get_user(user_id: int = Path(..., gt=0)):
return {"user_id": user_id}
That's it. Send user_id="abc"? FastAPI returns a detailed 422 error explaining the type mismatch. Send user_id=0? FastAPI returns a 422 explaining the constraint violation. The constraint gt=0 (greater than zero) is validated automatically. You never write if/else validation code — the type system IS the validation.
This works because FastAPI inspects the function signature at startup, builds a validation model from the type annotations, and applies it to every incoming request. It's not runtime magic — it's metaprogramming done right.
URL path segments become function parameters when wrapped in {braces}:
@app.get("/users/{user_id}")
def get_user(user_id: int):
return {"user_id": user_id}
@app.get("/users/{user_id}/posts/{post_id}")
def get_user_post(user_id: int, post_id: int):
return {"user_id": user_id, "post_id": post_id}
FastAPI converts path segments to the declared type automatically. Declare user_id: int and the conversion from string to integer happens behind the scenes.
For restricted choices, use Enum:
from enum import Enum
class ModelName(str, Enum):
alexnet = "alexnet"
resnet = "resnet"
lenet = "lenet"
@app.get("/models/{model_name}")
def get_model(model_name: ModelName):
if model_name is ModelName.alexnet:
return {"model_name": model_name, "message": "Deep Learning FTW!"}
return {"model_name": model_name, "message": "Some other model"}
Only the three valid model names are accepted. Send anything else and you get a 422 with the valid options listed. The OpenAPI docs show a dropdown with the options. Zero validation code written.
Function parameters that aren't in the URL path automatically become query parameters:
@app.get("/items/")
def list_items(
skip: int = 0,
limit: int = 10,
search: str | None = None,
in_stock: bool = True
):
result = {"skip": skip, "limit": limit, "in_stock": in_stock}
if search:
result["search"] = search
return result
Request: GET /items/?skip=20&limit=5&search=widget&in_stock=false
Default values make parameters optional. search: str | None = None means "optional string, defaults to None." skip: int = 0 means "optional integer, defaults to 0." A parameter without a default (like search: str) would be required.
FastAPI handles boolean conversion intelligently — true, True, 1, yes, on all work for True, and their counterparts for False.
For POST, PUT, and PATCH requests, you typically send data in the request body. FastAPI uses Pydantic models to define the shape of that data:
from pydantic import BaseModel, Field
from datetime import datetime
class ItemCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100,
examples=["Widget"])
description: str | None = Field(None, max_length=500)
price: float = Field(..., gt=0, le=1_000_000,
description="Price in USD")
tax: float | None = Field(None, ge=0)
tags: list[str] = Field(default_factory=list)
class ItemResponse(BaseModel):
id: int
name: str
price: float
price_with_tax: float | None = None
tags: list[str]
created_at: datetime
# In-memory "database" for this example
fake_db: dict[int, dict] = {}
next_id = 1
@app.post("/items/", response_model=ItemResponse,
status_code=201)
def create_item(item: ItemCreate):
global next_id
price_with_tax = item.price + item.tax if item.tax else None
db_item = {
"id": next_id,
"name": item.name,
"price": item.price,
"price_with_tax": price_with_tax,
"tags": item.tags,
"created_at": datetime.now(),
}
fake_db[next_id] = db_item
next_id += 1
return db_item
When a request comes in, FastAPI:
ItemCreate (checks types, constraints, required fields)ItemCreate instanceitemIf validation fails, FastAPI returns a 422 with detailed error messages — which field failed, why, and what was expected. The examples parameter even shows up in the OpenAPI docs as example values.
The response_model=ItemResponse ensures the response is serialized according to ItemResponse, even if your function returns a dict with extra fields. This is how you separate your internal models from your API contract.
A common pattern in real APIs: the data you accept for creation differs from what you return, and both differ from what's stored in your database:
class UserBase(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
email: str
full_name: str | None = None
class UserCreate(UserBase):
"""What the client sends when creating a user."""
password: str = Field(..., min_length=8)
class UserResponse(UserBase):
"""What the API returns — no password!"""
id: int
is_active: bool
class UserInDB(UserBase):
"""Internal model with hashed password."""
id: int
hashed_password: str
is_active: bool
@app.post("/users/", response_model=UserResponse)
def create_user(user: UserCreate):
# In reality you'd hash the password and save to DB
hashed = "fakehash_" + user.password
db_user = UserInDB(
id=1,
username=user.username,
email=user.email,
full_name=user.full_name,
hashed_password=hashed,
is_active=True,
)
return db_user # FastAPI filters through UserResponse
Even though the function returns a UserInDB object (which includes hashed_password), FastAPI serializes it through UserResponse — the password never appears in the API response. This model separation is a security best practice, and FastAPI makes it effortless.
FastAPI's dependency injection system is one of its most powerful features. Dependencies are functions that run before your endpoint and provide reusable logic:
from fastapi import Depends, HTTPException, Header
# Simple dependency: extract and validate auth token
def get_current_user(authorization: str = Header(...)):
"""Extract user from Authorization header."""
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Invalid auth scheme")
token = authorization[7:] # Strip "Bearer "
# In reality: decode JWT, query database, etc.
if token == "valid-token-123":
return {"user_id": 1, "username": "scipio", "role": "admin"}
raise HTTPException(status_code=401, detail="Invalid or expired token")
# Dependency that depends on another dependency
def require_admin(user: dict = Depends(get_current_user)):
"""Require the authenticated user to be an admin."""
if user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return user
# Use in endpoints
@app.get("/users/me")
def read_current_user(user: dict = Depends(get_current_user)):
return user
@app.delete("/users/{user_id}")
def delete_user(user_id: int, admin: dict = Depends(require_admin)):
return {"message": f"User {user_id} deleted by {admin['username']}"}
Dependencies can be chained: require_admin depends on get_current_user, so FastAPI resolves the full dependency graph automatically. The same dependency is instantiated once per request (not once per usage), so get_current_user runs only once even if multiple dependencies use it.
You can also apply dependencies at the router or app level:
from fastapi import APIRouter
# All endpoints in this router require authentication
admin_router = APIRouter(
prefix="/admin",
dependencies=[Depends(require_admin)]
)
@admin_router.get("/stats")
def admin_stats():
return {"users": 42, "items": 100}
app.include_router(admin_router)
FastAPI is built on Starlette (an ASGI framework) and natively supports async. If your endpoint does I/O (database queries, HTTP calls, file reads), use async def:
import httpx
@app.get("/github/{username}")
async def get_github_profile(username: str):
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://api.github.com/users/{username}",
headers={"Accept": "application/vnd.github.v3+json"}
)
if response.status_code == 404:
raise HTTPException(status_code=404, detail="GitHub user not found")
data = response.json()
return {
"login": data["login"],
"name": data.get("name"),
"public_repos": data["public_repos"],
"followers": data["followers"],
}
While one request waits for GitHub's API to respond, the server can handle other requests. As we covered in episodes #40 and #41 on async Python, this is cooperative concurrency — the await keyword yields control back to the event loop.
Important nuance: if your endpoint does only CPU-bound work (no I/O), use a regular def instead of async def. FastAPI runs regular def endpoints in a thread pool automatically, so they don't block the event loop. Using async def for CPU-bound work would block the entire server.
# Good: CPU-bound work in regular def (runs in thread pool)
@app.get("/compute")
def compute_heavy():
result = sum(i**2 for i in range(1_000_000))
return {"result": result}
# Good: I/O-bound work in async def
@app.get("/fetch")
async def fetch_data():
async with httpx.AsyncClient() as client:
resp = await client.get("https://api.example.com/data")
return resp.json()
FastAPI provides HTTPException for standard HTTP errors, and you can create custom exception handlers for domain-specific errors:
from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse
# Built-in HTTPException for standard errors
@app.get("/items/{item_id}")
def get_item(item_id: int):
if item_id not in fake_db:
raise HTTPException(
status_code=404,
detail=f"Item with id {item_id} not found",
headers={"X-Error": "Item not found"},
)
return fake_db[item_id]
# Custom exception class
class InsufficientStockError(Exception):
def __init__(self, item_id: int, requested: int, available: int):
self.item_id = item_id
self.requested = requested
self.available = available
# Register custom exception handler
@app.exception_handler(InsufficientStockError)
async def insufficient_stock_handler(request: Request, exc: InsufficientStockError):
return JSONResponse(
status_code=409,
content={
"error": "insufficient_stock",
"detail": f"Item {exc.item_id}: requested {exc.requested}, "
f"only {exc.available} available",
},
)
@app.post("/orders/")
def create_order(item_id: int, quantity: int):
available = 5 # Pretend this comes from DB
if quantity > available:
raise InsufficientStockError(item_id, quantity, available)
return {"order": "confirmed", "item_id": item_id, "quantity": quantity}
Custom exceptions let you define domain-specific error responses without cluttering your endpoint logic with try/except blocks.
Sometimes you need to do work after sending the response — sending emails, processing uploads, writing audit logs. FastAPI has built-in support for this:
from fastapi import BackgroundTasks
import time
def write_audit_log(user: str, action: str, item_id: int):
"""Simulate writing to an audit log (slow operation)."""
time.sleep(2) # Pretend this writes to a file or database
print(f"AUDIT: {user} performed {action} on item {item_id}")
def send_notification(email: str, subject: str):
"""Simulate sending a notification email."""
time.sleep(3)
print(f"EMAIL: Sent '{subject}' to {email}")
@app.post("/items/{item_id}/purchase")
def purchase_item(
item_id: int,
background_tasks: BackgroundTasks,
user: dict = Depends(get_current_user)
):
# Core logic — fast
result = {"status": "purchased", "item_id": item_id}
# Background tasks — slow, non-blocking
background_tasks.add_task(
write_audit_log, user["username"], "purchase", item_id
)
background_tasks.add_task(
send_notification, "buyer@example.com",
f"Purchase confirmed: item #{item_id}"
)
return result # Returns immediately, tasks run after response sent
The response returns instantly. The audit log and email happen in the background. This is simpler than setting up Celery or a task queue for lightweight background work.
As your API grows, you split it into routers (similar to Flask's Blueprints):
# routers/items.py
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/items", tags=["items"])
class Item(BaseModel):
name: str
price: float
items_db: dict[int, Item] = {}
@router.get("/")
def list_items(skip: int = 0, limit: int = 10):
items = list(items_db.values())
return items[skip : skip + limit]
@router.get("/{item_id}")
def get_item(item_id: int):
if item_id not in items_db:
raise HTTPException(status_code=404, detail="Item not found")
return items_db[item_id]
@router.post("/", status_code=201)
def create_item(item: Item):
item_id = len(items_db) + 1
items_db[item_id] = item
return {"id": item_id, **item.model_dump()}
# main.py
from fastapi import FastAPI
from routers import items, users # Your router modules
app = FastAPI(title="My Store API")
app.include_router(items.router)
app.include_router(users.router)
@app.get("/")
def root():
return {"message": "Welcome to My Store API", "docs": "/docs"}
Each router handles a domain (items, users, orders), with its own prefix and tags. The tags group endpoints in the OpenAPI docs — so your /docs page is organized by domain, not by the order you wrote the code.
FastAPI includes a TestClient (powered by httpx) for writing tests without starting a server:
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_read_root():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "Hello World"}
def test_create_item():
response = client.post(
"/items/",
json={"name": "Test Widget", "price": 9.99}
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test Widget"
assert "id" in data
def test_create_item_invalid():
response = client.post(
"/items/",
json={"name": "", "price": -5} # Invalid!
)
assert response.status_code == 422 # Validation error
def test_get_nonexistent_item():
response = client.get("/items/99999")
assert response.status_code == 404
Run with pytest. The TestClient sends requests to your app in-process — no server needed, no network overhead. This integrates perfectly with the testing patterns we covered in episodes #38 and #39.
Let me show the same CRUD API in both frameworks, so you can see the difference concretely:
# Flask version — 35 lines, manual validation
from flask import Flask, request, jsonify
app = Flask(__name__)
items = {}
@app.route("/items/", methods=["POST"])
def create_item():
data = request.get_json()
if not data:
return jsonify({"error": "No JSON body"}), 400
if "name" not in data or not isinstance(data["name"], str):
return jsonify({"error": "name must be a string"}), 400
if "price" not in data or not isinstance(data["price"], (int, float)):
return jsonify({"error": "price must be a number"}), 400
if data["price"] <= 0:
return jsonify({"error": "price must be positive"}), 400
item_id = len(items) + 1
items[item_id] = data
return jsonify({"id": item_id, **data}), 201
# FastAPI version — 15 lines, automatic validation
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
items = {}
class ItemCreate(BaseModel):
name: str = Field(..., min_length=1)
price: float = Field(..., gt=0)
@app.post("/items/", status_code=201)
def create_item(item: ItemCreate):
item_id = len(items) + 1
items[item_id] = item.model_dump()
return {"id": item_id, **item.model_dump()}
Half the code. Better error messages. Automatic documentation. Async support if you need it later. And the type hints serve triple duty: validation, documentation, and editor autocomplete.
In this episode, we explored FastAPI fundamentals:
response_model controls the response shape, filtering sensitive fields automaticallyasync def) enable high-concurrency I/O; regular def endpoints auto-run in thread poolsTestClient enables fast, in-process testing without a running server/docs and /redoc) are generated automatically from your codeFastAPI shows what's possible when a framework is designed around modern Python features rather than retrofitting them. The type system is the specification. The code is the documentation. That's not just convenient — it means your docs can never fall out of sync with your implementation, because they're the same thing ;-)