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

in StemSocial2 days ago

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

python-logo.png

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):

GitHub Account

https://github.com/realScipio

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 JSON
  • http://localhost:8000/items/42?q=test — path parameter + query parameter
  • http://localhost:8000/docsinteractive 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:

  1. Reads the JSON body
  2. Validates it against ItemCreate (checks types, constraints, required fields)
  3. Converts it to an ItemCreate instance
  4. 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_model controls 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; regular def endpoints 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
  • TestClient enables fast, in-process testing without a running server
  • OpenAPI docs (/docs and /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 ;-)

Dank je en tot de volgende keer!

@scipio