Learn Python Series (#49) - FastAPI Basics - Modern Web APIs

Repository
What will I learn
- You will learn what makes FastAPI different from Flask and Django REST Framework;
- how type hints enable automatic validation, serialization, and documentation;
- the mental model behind FastAPI's async-first design;
- how to build REST APIs with minimal boilerplate using Pydantic models;
- why FastAPI generates OpenAPI documentation automatically from your code;
- how dependency injection, background tasks, and error handling work.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Python 3(.11+) distribution, such as (for example) the Anaconda Distribution;
- The ambition to learn Python programming.
Difficulty
- Intermediate
Curriculum (of the Learn Python Series):
- Learn Python Series - Intro
- Learn Python Series (#2) - Handling Strings Part 1
- Learn Python Series (#3) - Handling Strings Part 2
- Learn Python Series (#4) - Round-Up #1
- Learn Python Series (#5) - Handling Lists Part 1
- Learn Python Series (#6) - Handling Lists Part 2
- Learn Python Series (#7) - Handling Dictionaries
- Learn Python Series (#8) - Handling Tuples
- Learn Python Series (#9) - Using Import
- Learn Python Series (#10) - Matplotlib Part 1
- Learn Python Series (#11) - NumPy Part 1
- Learn Python Series (#12) - Handling Files
- Learn Python Series (#13) - Mini Project - Developing a Web Crawler Part 1
- Learn Python Series (#14) - Mini Project - Developing a Web Crawler Part 2
- Learn Python Series (#15) - Handling JSON
- Learn Python Series (#16) - Mini Project - Developing a Web Crawler Part 3
- Learn Python Series (#17) - Roundup #2 - Combining and analyzing any-to-any multi-currency historical data
- Learn Python Series (#18) - PyMongo Part 1
- Learn Python Series (#19) - PyMongo Part 2
- Learn Python Series (#20) - PyMongo Part 3
- Learn Python Series (#21) - Handling Dates and Time Part 1
- Learn Python Series (#22) - Handling Dates and Time Part 2
- Learn Python Series (#23) - Handling Regular Expressions Part 1
- Learn Python Series (#24) - Handling Regular Expressions Part 2
- Learn Python Series (#25) - Handling Regular Expressions Part 3
- Learn Python Series (#26) - pipenv & Visual Studio Code
- Learn Python Series (#27) - Handling Strings Part 3 (F-Strings)
- Learn Python Series (#28) - Using Pickle and Shelve
- Learn Python Series (#29) - Handling CSV
- Learn Python Series (#30) - Data Science Part 1 - Pandas
- Learn Python Series (#31) - Data Science Part 2 - Pandas
- Learn Python Series (#32) - Data Science Part 3 - Pandas
- Learn Python Series (#33) - Data Science Part 4 - Pandas
- Learn Python Series (#34) - Working with APIs in 2026: What's Changed
- Learn Python Series (#35) - Working with APIs Part 2: Beyond GET Requests
- Learn Python Series (#36) - Type Hints and Modern Python
- Learn Python Series (#37) - Virtual Environments and Dependency Management
- Learn Python Series (#38) - Testing Your Code Part 1
- Learn Python Series (#39) - Testing Your Code Part 2
- Learn Python Series (#40) - Asynchronous Python Part 1
- Learn Python Series (#41) - Asynchronous Python Part 2
- Learn Python Series (#42) - Building CLI Applications
- Learn Python Series (#43) - Mini Project - Crypto Price Tracker
- Learn Python Series (#44) - Context Managers & Decorators Deep Dive
- Learn Python Series (#45) - Metaclasses & Class Design Patterns
- Learn Python Series (#46) - Descriptors & Properties
- Learn Python Series (#47) - Generators & Iterators Advanced
- Learn Python Series (#48) - Concurrency - Threading vs Multiprocessing
- Learn Python Series (#49) - FastAPI Basics - Modern Web APIs (this post)
GitHub Account
Learn Python Series (#49) - FastAPI Basics - Modern Web APIs
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.
Installation and your first API
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 documentation
That 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).
The mental model: types drive everything
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/<user_id>", 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.
Path parameters
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.
Query parameters
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.
Request body with Pydantic models
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:
- Reads the JSON body
- Validates it against
ItemCreate(checks types, constraints, required fields) - Converts it to an
ItemCreateinstance - Passes it to your function as
item
If 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.
Separating input and output models
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.
Dependency injection
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)
Async endpoints
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()
Error handling
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.
Background tasks
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, "[email protected]",
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.
Organizing a larger application with routers
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.
Testing FastAPI applications
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.
Flask vs. FastAPI: a direct comparison
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.
Zo, samengevat
In this episode, we explored FastAPI fundamentals:
- Type hints drive everything — validation, serialization, documentation, and editor support
- Pydantic models define request/response schemas declaratively with built-in constraints
- Path parameters come from URL segments, query parameters from function args not in the path
response_modelcontrols the response shape, filtering sensitive fields automatically- Dependency injection provides reusable, composable logic (auth, DB connections, rate limiting)
- Async endpoints (
async def) enable high-concurrency I/O; regulardefendpoints auto-run in thread pools - Custom exception handlers keep domain errors out of endpoint logic
- Background tasks run after the response is sent — no task queue needed for simple cases
- Routers organize large APIs by domain with prefixes and tags
TestClientenables fast, in-process testing without a running server- OpenAPI docs (
/docsand/redoc) are generated automatically from your code
FastAPI 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 ;-)