Chapter 41: Web Development with FastAPI
FastAPI is the fastest-growing Python web framework. It's built for APIs, runs on async Python, validates data automatically using type hints, and generates interactive documentation for free.
Companies like Uber, Netflix, and Microsoft use it. If you're building an API in 2026, FastAPI is the default choice.
What Makes FastAPI Different?
| Feature | Flask | FastAPI |
|---|---|---|
| Speed | Fast | Very fast (Starlette + async) |
| Data validation | Manual | Automatic (Pydantic) |
| API docs | Manual | Auto-generated (Swagger + ReDoc) |
| Type hints | Optional | Built-in, used for validation |
| Async support | Limited | First-class |
| Learning curve | Lower | Slightly higher |
You already know Flask from Chapter 40. FastAPI feels familiar but more powerful.
Setup
pip install fastapi uvicorn[standard] pydantic
uvicorn is the ASGI server that runs FastAPI. Think of it like gunicorn for Flask, but async.
Your First FastAPI App
# main.py
from fastapi import FastAPI
app = FastAPI(title="My API", version="1.0.0")
@app.get("/")
def home():
return {"message": "Hello, World!"}
@app.get("/about")
def about():
return {"app": "My API", "author": "Alice"}
Run it:
uvicorn main:app --reload
Open http://127.0.0.1:8000/ — you'll see the JSON response.
Now open http://127.0.0.1:8000/docs — you get a full interactive Swagger UI. Every endpoint is documented, testable, and shows request/response schemas. No extra code required.
--reload restarts the server on code changes, like debug=True in Flask.
Path Parameters
@app.get("/users/{user_id}")
def get_user(user_id: int):
# FastAPI validates that user_id is an int
# If you pass /users/abc -> automatic 422 error
return {"user_id": user_id}
@app.get("/items/{item_name}")
def get_item(item_name: str):
return {"item": item_name}
The type hint user_id: int does two things at once:
- Converts the string from the URL to an integer
- Returns a
422 Unprocessable Entityerror if it's not a valid integer
Query Parameters
from typing import Optional
@app.get("/search")
def search(
q: str,
page: int = 1,
per_page: int = 10,
active: Optional[bool] = None,
):
# /search?q=python
# /search?q=python&page=2&per_page=20
# /search?q=python&active=true
return {
"query": q,
"page": page,
"per_page": per_page,
"active": active,
}
Parameters with default values are optional query params. Parameters without defaults are required.
Request Bodies with Pydantic
This is where FastAPI gets powerful. Define a Pydantic model and FastAPI uses it for:
- Automatic JSON parsing
- Validation
- Error messages
- Documentation
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
class UserCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
email: str = Field(..., description="User's email address")
age: int = Field(..., ge=0, le=150)
bio: Optional[str] = None
@app.post("/users", status_code=201)
def create_user(user: UserCreate):
# user is already validated — if we get here, it's clean data
return {
"id": 42,
"name": user.name,
"email": user.email,
"age": user.age,
}
If you send this:
{"name": "", "email": "not-an-email", "age": -5}
You get back:
{
"detail": [
{"loc": ["body", "name"], "msg": "ensure this value has at least 1 character"},
{"loc": ["body", "age"], "msg": "ensure this value is greater than or equal to 0"}
]
}
Zero code written for validation. Pydantic handles everything.
Field validators
from pydantic import BaseModel, field_validator
import re
class UserCreate(BaseModel):
username: str
password: str
email: str
@field_validator("username")
@classmethod
def username_alphanumeric(cls, v: str) -> str:
if not v.isalnum():
raise ValueError("Username must be alphanumeric")
return v.lower()
@field_validator("password")
@classmethod
def password_strong(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
return v
@field_validator("email")
@classmethod
def email_valid(cls, v: str) -> str:
if "@" not in v:
raise ValueError("Invalid email address")
return v.lower()
Response Models
Define what the API returns separately from what it accepts. This lets you hide sensitive fields (like passwords) from responses:
from pydantic import BaseModel
from datetime import datetime
class UserCreate(BaseModel):
name: str
email: str
password: str # accepted in request
class UserOut(BaseModel):
id: int
name: str
email: str
created_at: datetime
# password is NOT here — never returned
@app.post("/users", response_model=UserOut, status_code=201)
def create_user(user: UserCreate):
# In a real app: hash password, save to DB
return UserOut(
id=1,
name=user.name,
email=user.email,
created_at=datetime.utcnow(),
)
FastAPI filters the response through UserOut automatically — even if you return extra fields, they won't appear in the output.
A Full CRUD API
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
app = FastAPI(title="Task API", version="1.0.0")
# ── Models ────────────────────────────────────────────────────────────────────
class TaskCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
description: Optional[str] = None
priority: int = Field(1, ge=1, le=5)
class TaskUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = None
priority: Optional[int] = Field(None, ge=1, le=5)
done: Optional[bool] = None
class TaskOut(BaseModel):
id: int
title: str
description: Optional[str]
priority: int
done: bool
created_at: datetime
# ── In-memory store ───────────────────────────────────────────────────────────
tasks: dict[int, TaskOut] = {}
next_id: int = 1
# ── Endpoints ─────────────────────────────────────────────────────────────────
@app.get("/tasks", response_model=list[TaskOut])
def list_tasks(
done: Optional[bool] = Query(None, description="Filter by done status"),
priority: Optional[int] = Query(None, ge=1, le=5),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
result = list(tasks.values())
if done is not None:
result = [t for t in result if t.done == done]
if priority is not None:
result = [t for t in result if t.priority == priority]
return result[offset : offset + limit]
@app.post("/tasks", response_model=TaskOut, status_code=201)
def create_task(task: TaskCreate):
global next_id
new_task = TaskOut(
id=next_id,
title=task.title,
description=task.description,
priority=task.priority,
done=False,
created_at=datetime.utcnow(),
)
tasks[next_id] = new_task
next_id += 1
return new_task
@app.get("/tasks/{task_id}", response_model=TaskOut)
def get_task(task_id: int):
task = tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return task
@app.patch("/tasks/{task_id}", response_model=TaskOut)
def update_task(task_id: int, updates: TaskUpdate):
task = tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
task_data = task.model_dump()
update_data = updates.model_dump(exclude_unset=True) # only changed fields
task_data.update(update_data)
tasks[task_id] = TaskOut(**task_data)
return tasks[task_id]
@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int):
if task_id not in tasks:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
del tasks[task_id]
Visit http://127.0.0.1:8000/docs to test every endpoint interactively.
Async Endpoints
FastAPI is built on asyncio. Use async def for I/O-bound routes:
import asyncio
import httpx # async HTTP client
@app.get("/weather/{city}")
async def get_weather(city: str):
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://wttr.in/{city}?format=j1",
timeout=10,
)
response.raise_for_status()
data = response.json()
return {
"city": city,
"temp_c": data["current_condition"][0]["temp_C"],
"description": data["current_condition"][0]["weatherDesc"][0]["value"],
}
pip install httpx
Use async def when your endpoint does I/O (database queries, HTTP calls, file reads). Use regular def for CPU-bound or synchronous code. FastAPI handles both correctly.
Dependency Injection
Dependencies let you share logic across endpoints — database connections, authentication, pagination params:
from fastapi import Depends
# ── A reusable "pagination" dependency ────────────────────────────────────────
class Pagination:
def __init__(self, limit: int = 20, offset: int = 0):
self.limit = limit
self.offset = offset
@app.get("/products")
def list_products(pagination: Pagination = Depends()):
# pagination.limit and pagination.offset are set automatically
return {"limit": pagination.limit, "offset": pagination.offset}
# ── A reusable "current user" dependency ──────────────────────────────────────
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
token = credentials.credentials
# In a real app: decode JWT, look up user in DB
if token != "valid-token":
raise HTTPException(status_code=401, detail="Invalid token")
return {"id": 1, "name": "Alice"}
@app.get("/me")
def get_me(current_user: dict = Depends(get_current_user)):
return current_user
@app.get("/dashboard")
def dashboard(current_user: dict = Depends(get_current_user)):
return {"welcome": current_user["name"]}
Both /me and /dashboard are now protected without repeating the auth logic.
Authentication with JWT
A realistic authentication flow:
pip install python-jose[cryptography] passlib[bcrypt]
from datetime import datetime, timedelta
from typing import Optional
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel
SECRET_KEY = "super-secret-key-change-this"
ALGORITHM = "HS256"
TOKEN_EXPIRY = timedelta(hours=24)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()
# Fake user database
USERS_DB = {
"alice": {
"username": "alice",
"hashed_password": pwd_context.hash("password123"),
"email": "alice@example.com",
}
}
class Token(BaseModel):
access_token: str
token_type: str
class UserOut(BaseModel):
username: str
email: str
def create_access_token(data: dict) -> str:
payload = data.copy()
payload["exp"] = datetime.utcnow() + TOKEN_EXPIRY
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username = payload.get("sub")
if username is None:
raise HTTPException(status_code=401, detail="Invalid token")
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
user = USERS_DB.get(username)
if user is None:
raise HTTPException(status_code=401, detail="User not found")
return user
@app.post("/token", response_model=Token)
def login(form: OAuth2PasswordRequestForm = Depends()):
user = USERS_DB.get(form.username)
if not user or not pwd_context.verify(form.password, user["hashed_password"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
token = create_access_token({"sub": user["username"]})
return {"access_token": token, "token_type": "bearer"}
@app.get("/me", response_model=UserOut)
def get_me(current_user: dict = Depends(get_current_user)):
return current_user
Test it in /docs — click "Authorize," log in as alice/password123, then call /me.
Lifespan Events — Startup and Shutdown
Use lifespan to run code once at startup and shutdown (connect to DB, warm up caches):
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
print("Starting up — connecting to database...")
app.state.db = {} # replace with real DB connection
yield # app is running here
# Shutdown
print("Shutting down — closing connections...")
app = FastAPI(lifespan=lifespan)
@app.get("/")
def home():
return {"status": "running"}
Middleware
Middleware runs before and after every request:
import time
from fastapi import Request
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
start = time.time()
response = await call_next(request)
duration = time.time() - start
response.headers["X-Process-Time"] = f"{duration:.4f}s"
return response
Add CORS support (required for browser-based frontends):
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "https://myapp.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Background Tasks
Run slow tasks (sending emails, processing files) after returning the response:
from fastapi import BackgroundTasks
def send_welcome_email(email: str, name: str):
import time
time.sleep(2) # simulate slow email sending
print(f"Email sent to {email}: Welcome, {name}!")
@app.post("/register")
def register(
name: str,
email: str,
background_tasks: BackgroundTasks,
):
# This returns immediately
background_tasks.add_task(send_welcome_email, email, name)
return {"message": f"Registered {name}. Welcome email on its way!"}
Deploying FastAPI
Option 1: Render
# Procfile or render.yaml start command:
uvicorn main:app --host 0.0.0.0 --port $PORT
Option 2: Docker
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
docker build -t myapi .
docker run -p 8000:8000 myapi
Option 3: Multiple workers with Gunicorn
pip install gunicorn
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker
Flask vs FastAPI — When to Use Which
Use Flask when:
- Building a server-rendered website with HTML templates
- You need a simple API with no special requirements
- Your team already knows Flask
- You need the massive Flask ecosystem (extensions)
Use FastAPI when:
- Building a REST or GraphQL API
- You want automatic validation and docs
- Performance matters
- You're using async Python
- You're starting a new project today
What You Learned in This Chapter
FastAPI()creates the app.uvicorn main:app --reloadruns it.- Type hints on function parameters do triple duty: URL parsing, validation, and docs generation.
response_model=controls what fields are returned — use it to hide sensitive data.HTTPException(status_code=..., detail=...)returns clean error responses.- Pydantic
BaseModelsubclasses define request and response shapes.Field(...)adds validation rules.@field_validatoradds custom checks. Depends()injects shared logic (auth, DB, pagination) into endpoints.async defroutes handle I/O-bound work efficiently.- JWT auth:
OAuth2PasswordBearer+python-jose+passlib. lifespancontext manager handles startup/shutdown.BackgroundTasksruns work after the response is sent.- Auto-generated docs at
/docs(Swagger) and/redoc(ReDoc).
What's Next?
Chapter 42 covers NumPy — the numerical computing library at the heart of Python's data science ecosystem. You'll learn how to work with arrays, perform vectorized math, and process numerical data orders of magnitude faster than plain Python lists.