On this page

Intro to FastAPI

14 min read TextCh. 5 — Python for Web

Intro to FastAPI

FastAPI is a modern, high-performance Python web framework for building APIs. It is built on top of Starlette (the async web layer) and Pydantic (the data validation layer), and it leverages Python's type hints throughout. FastAPI generates interactive API documentation automatically, validates request and response data, and is one of the fastest Python web frameworks available — benchmarking close to Node.js and Go for I/O-bound workloads.

Why FastAPI?

  • Speed: Built on ASGI with async/await support — handles thousands of concurrent requests.
  • Type safety: Uses Python type hints to validate inputs and outputs automatically.
  • Auto docs: Generates Swagger UI (/docs) and ReDoc (/redoc) with zero configuration.
  • Pydantic: Data validation with clear error messages and automatic JSON serialization.
  • Developer experience: Editor autocompletion and inline validation everywhere.

Installation

pip install fastapi uvicorn[standard]

uvicorn is the ASGI server that runs your FastAPI app. The [standard] extra adds the uvloop and httptools for maximum performance.

Your First FastAPI App

Create a file called main.py:

from fastapi import FastAPI

app = FastAPI(title="My API", version="1.0.0")

@app.get("/")
def read_root() -> dict:
    return {"message": "Hello from FastAPI!", "status": "ok"}

@app.get("/health")
def health_check() -> dict:
    return {"status": "healthy"}

Run it:

uvicorn main:app --reload

The --reload flag restarts the server automatically when you change the code. Open your browser at:

  • http://127.0.0.1:8000/ — your API root
  • http://127.0.0.1:8000/docs — Swagger UI (interactive)
  • http://127.0.0.1:8000/redoc — ReDoc documentation

Path Parameters

from fastapi import FastAPI, HTTPException

app = FastAPI()

ITEMS = {
    1: {"name": "Laptop", "price": 999.99},
    2: {"name": "Mouse", "price": 29.99},
    3: {"name": "Keyboard", "price": 79.99},
}

@app.get("/items/{item_id}")
def get_item(item_id: int) -> dict:
    """Retrieve an item by its ID."""
    if item_id not in ITEMS:
        raise HTTPException(status_code=404, detail=f"Item {item_id} not found")
    return ITEMS[item_id]

@app.get("/users/{username}/posts/{post_id}")
def get_user_post(username: str, post_id: int) -> dict:
    return {"username": username, "post_id": post_id}

FastAPI automatically converts item_id from a string (all URL parameters are strings) to int based on the type hint, and returns a 422 Unprocessable Entity if conversion fails (e.g., /items/abc).

Query Parameters

from fastapi import FastAPI, Query

app = FastAPI()

PRODUCTS = [
    {"id": 1, "name": "Widget", "category": "tools", "price": 9.99},
    {"id": 2, "name": "Gadget", "category": "electronics", "price": 49.99},
    {"id": 3, "name": "Doohickey", "category": "tools", "price": 14.99},
    {"id": 4, "name": "Thingamajig", "category": "electronics", "price": 99.99},
]

@app.get("/products")
def list_products(
    category: str | None = None,
    min_price: float = 0.0,
    max_price: float = 10_000.0,
    skip: int = Query(0, ge=0),             # ge=0 means >= 0
    limit: int = Query(10, ge=1, le=100),   # between 1 and 100
) -> list[dict]:
    """List products with optional filtering and pagination."""
    results = [
        p for p in PRODUCTS
        if (category is None or p["category"] == category)
        and min_price <= p["price"] <= max_price
    ]
    return results[skip: skip + limit]

URL: GET /products?category=tools&min_price=5&limit=5

Pydantic Models — Request and Response Schemas

Pydantic models define the shape of your data. FastAPI uses them for request body validation and response serialization:

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, EmailStr, Field, field_validator
from datetime import datetime

app = FastAPI()

class UserCreate(BaseModel):
    """Schema for creating a user — this is what the client sends."""
    username: str = Field(min_length=3, max_length=30, pattern=r"^[a-zA-Z0-9_]+$")
    email: str
    age: int = Field(ge=13, le=120, description="Age in years (must be at least 13)")
    bio: str | None = Field(None, max_length=500)

    @field_validator("email")
    @classmethod
    def validate_email(cls, v: str) -> str:
        if "@" not in v or "." not in v.split("@")[-1]:
            raise ValueError("Invalid email address")
        return v.lower()


class UserResponse(BaseModel):
    """Schema for the response — what the server returns."""
    id: int
    username: str
    email: str
    age: int
    bio: str | None
    created_at: datetime
    is_active: bool = True


# In-memory "database"
_users: dict[int, dict] = {}
_next_id = 1

@app.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def create_user(user: UserCreate) -> dict:
    """Create a new user."""
    global _next_id
    new_user = {
        "id": _next_id,
        "username": user.username,
        "email": user.email,
        "age": user.age,
        "bio": user.bio,
        "created_at": datetime.utcnow(),
        "is_active": True,
    }
    _users[_next_id] = new_user
    _next_id += 1
    return new_user


@app.get("/users/{user_id}", response_model=UserResponse)
def get_user(user_id: int) -> dict:
    if user_id not in _users:
        raise HTTPException(status_code=404, detail=f"User {user_id} not found")
    return _users[user_id]


@app.get("/users", response_model=list[UserResponse])
def list_users(skip: int = 0, limit: int = 10) -> list[dict]:
    all_users = list(_users.values())
    return all_users[skip: skip + limit]

tip type: tip title: "Separate request and response models"

Always use different Pydantic models for input (request body) and output (response). Input models contain Field validators and only the fields the client is allowed to set. Response models define what the server returns and may include generated fields like id and created_at. This separation prevents accidentally exposing internal fields.

Update and Delete Endpoints

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI()

class ProductUpdate(BaseModel):
    name: str | None = None
    price: float | None = None
    stock: int | None = None

INVENTORY: dict[int, dict] = {
    1: {"id": 1, "name": "Laptop", "price": 999.99, "stock": 50},
    2: {"id": 2, "name": "Mouse", "price": 29.99, "stock": 200},
}

@app.patch("/inventory/{product_id}")
def update_product(product_id: int, update: ProductUpdate) -> dict:
    """Partially update a product."""
    if product_id not in INVENTORY:
        raise HTTPException(status_code=404, detail="Product not found")

    product = INVENTORY[product_id]
    # model_dump(exclude_unset=True) returns only the fields the client actually sent
    changes = update.model_dump(exclude_unset=True)
    product.update(changes)
    return product

@app.delete("/inventory/{product_id}", status_code=204)
def delete_product(product_id: int) -> None:
    if product_id not in INVENTORY:
        raise HTTPException(status_code=404, detail="Product not found")
    del INVENTORY[product_id]

Dependency Injection

FastAPI has a powerful dependency injection system perfect for shared logic like authentication, database connections, and pagination:

from fastapi import FastAPI, Depends, Header, HTTPException

app = FastAPI()

def get_current_user(x_api_key: str = Header(...)) -> dict:
    """Extract and validate the API key from the X-Api-Key header."""
    valid_keys = {"secret123": {"user": "alice", "role": "admin"}}
    if x_api_key not in valid_keys:
        raise HTTPException(status_code=401, detail="Invalid API key")
    return valid_keys[x_api_key]

def admin_only(current_user: dict = Depends(get_current_user)) -> dict:
    if current_user["role"] != "admin":
        raise HTTPException(status_code=403, detail="Admin access required")
    return current_user

@app.get("/admin/stats")
def admin_stats(user: dict = Depends(admin_only)) -> dict:
    """Only accessible with a valid admin API key."""
    return {"message": f"Hello, {user['user']}! Here are your stats.", "total_users": 42}

Async Endpoints

FastAPI natively supports async route handlers — perfect for database queries, HTTP calls to other services, and I/O-bound work:

import asyncio
import httpx
from fastapi import FastAPI

app = FastAPI()

@app.get("/posts/{post_id}/with-user")
async def get_post_with_user(post_id: int) -> dict:
    """Fetch a post and its author concurrently."""
    async with httpx.AsyncClient(timeout=10.0) as client:
        # Run both requests concurrently
        post_task = client.get(f"https://jsonplaceholder.typicode.com/posts/{post_id}")
        user_id_task = asyncio.create_task(
            client.get(f"https://jsonplaceholder.typicode.com/posts/{post_id}")
        )

        post_response = await post_task
        post_response.raise_for_status()
        post = post_response.json()

        user_response = await client.get(
            f"https://jsonplaceholder.typicode.com/users/{post['userId']}"
        )
        user_response.raise_for_status()
        user = user_response.json()

    return {"post": post, "author": {"name": user["name"], "email": user["email"]}}

tip type: info title: "When to use async"

Use async def route handlers when your endpoint does I/O — database queries, HTTP calls to other services, reading files. Use plain def for CPU-bound operations (FastAPI runs them in a thread pool automatically). Mixing async and blocking calls (like requests.get) inside an async function will block the event loop — use httpx.AsyncClient instead.

nextSteps

  • decorators-and-type-hints