🟩 FastAPI

FastAPI Intro & Routing

📖 Lesson 37 ⏱ 50 min 🧪 5 questions 💻 3 exercises
🎯 Learning Objectives
  1. Understand what FastAPI is and how it compares to Flask and Django REST Framework
  2. Install FastAPI and run a development server with Uvicorn
  3. Define path operations with @app.get, @app.post, @app.put, @app.delete, @app.patch
  4. Use path parameters, query parameters, and default values
  5. Validate request bodies with Pydantic models
  6. Return typed responses with response_model and correct HTTP status codes
  7. Organise routes into reusable APIRouter modules

1 · What is FastAPI?

FastAPI is a modern, high-performance web framework for building APIs with Python. It sits on top of Starlette (the ASGI toolkit that gives it speed and async support) and Pydantic (which provides data parsing and validation via type hints). Because it is ASGI-native, FastAPI can handle WebSockets, background tasks, and thousands of concurrent connections with the same familiar async def syntax.

One of FastAPI's biggest selling points is that it automatically generates interactive API documentation from your code. Visit /docs for Swagger UI or /redoc for ReDoc — both are driven by the OpenAPI schema FastAPI builds as it registers your routes.

Key differentiators at a glance:

  • Async-first — path operations can be plain def or async def; FastAPI runs sync handlers in a thread pool so they never block the event loop.
  • Type-hint-driven validation — annotate a parameter user_id: int and FastAPI validates, converts, and documents it automatically.
  • Zero-boilerplate serialisation — return a dict, a Pydantic model, or a list; FastAPI handles JSON encoding and response headers.

Comparison with Flask and Django REST Framework

Feature FastAPI Flask Django REST
ASGI / async native ❌ (WSGI) Partial
Auto OpenAPI docs ❌ (plugin) ❌ (plugin)
Pydantic validation ✅ built-in ❌ manual ❌ serializers
Performance ⚡ Very high Medium Medium
Learning curve Low–Medium Low High
Best for APIs, microservices Simple apps/APIs Full-stack Django
Concept: FastAPI is not a full-stack framework — it has no templating system, ORM, or admin panel built in. It is purpose-built for building APIs. Pair it with SQLAlchemy or SQLModel for database access and Jinja2 if you need server-side HTML.

2 · Installation & First App

Install FastAPI together with Uvicorn (the ASGI server):

pip install fastapi uvicorn[standard]
terminal

The [standard] extra pulls in httptools and uvloop for maximum performance, plus python-multipart for form data and websockets for WebSocket support.

Create your first app in a file called main.py:

# main.py
from fastapi import FastAPI

app = FastAPI(
    title="My API",
    description="A sample FastAPI application",
    version="1.0.0",
)

@app.get("/")
def root():
    return {"message": "Hello, World!"}
main.py

Start the development server:

uvicorn main:app --reload
# Serving at http://127.0.0.1:8000
# Docs at   http://127.0.0.1:8000/docs
terminal

The argument main:app tells Uvicorn to look in the module main for an object called app. The --reload flag enables auto-restart whenever a source file changes — essential during development, but remove it in production.

Tip: Visit http://127.0.0.1:8000/docs to see the interactive Swagger UI. All your endpoints are documented automatically from type hints — you can even send live requests directly from the browser without any extra setup.

3 · Path Operations & HTTP Methods

A path operation is a combination of an HTTP method and a URL path. FastAPI registers them through decorators on the app object:

  • @app.get("/path") — read a resource or list of resources
  • @app.post("/path") — create a new resource
  • @app.put("/path/{id}") — fully replace a resource
  • @app.patch("/path/{id}") — partially update a resource
  • @app.delete("/path/{id}") — remove a resource
from fastapi import FastAPI

app = FastAPI()

@app.get("/items")             # GET  — list or read
def list_items():
    return [{"id": 1}, {"id": 2}]

@app.post("/items")            # POST — create
def create_item():
    return {"created": True}

@app.put("/items/{item_id}")   # PUT  — full replace
def replace_item(item_id: int):
    return {"replaced": item_id}

@app.patch("/items/{item_id}") # PATCH — partial update
def update_item(item_id: int):
    return {"updated": item_id}

@app.delete("/items/{item_id}") # DELETE — remove
def delete_item(item_id: int):
    return {"deleted": item_id}
main.py

Each function is called a path operation function. It can be a plain def or an async def — FastAPI handles both correctly. The return value is automatically serialised to JSON.

4 · Path Parameters

Path parameters are declared with curly braces in the route string ({user_id}) and as typed function arguments. FastAPI converts the raw string from the URL to the declared Python type and raises a validation error if the conversion fails.

from fastapi import FastAPI

app = FastAPI()

# Basic path parameter — type annotation does the validation
@app.get("/users/{user_id}")
def get_user(user_id: int):       # FastAPI converts & validates automatically
    return {"user_id": user_id}

# Multiple path parameters
@app.get("/users/{user_id}/posts/{post_id}")
def get_post(user_id: int, post_id: int):
    return {"user_id": user_id, "post_id": post_id}

# Enum parameter — constrains accepted values
from enum import Enum

class ModelSize(str, Enum):
    small  = "small"
    medium = "medium"
    large  = "large"

@app.get("/models/{size}")
def get_model(size: ModelSize):
    params = {"small": "7B", "medium": "13B", "large": "70B"}
    return {"size": size, "params": params[size]}
main.py

Using a str, Enum subclass constrains the accepted values at both the validation layer and in the generated OpenAPI schema — the Swagger UI will display a dropdown instead of a free-text field.

Concept: If the client sends GET /users/abc, FastAPI automatically returns 422 Unprocessable Entity with a structured error body that describes exactly which field failed and why — no manual validation code needed.

5 · Query Parameters

Any function parameter that is not declared in the route path is treated as a query parameter (e.g. GET /items?skip=10&limit=5). FastAPI reads the value from the URL query string, converts it to the annotated type, and applies the default if the parameter is absent.

from fastapi import FastAPI
from typing import Optional

app = FastAPI()

# Query params are any function params that are NOT path params
@app.get("/items")
def list_items(
    skip: int = 0,                 # default 0;  GET /items?skip=10
    limit: int = 10,               # default 10; GET /items?limit=5
    search: Optional[str] = None,  # optional;   GET /items?search=hello
):
    return {"skip": skip, "limit": limit, "search": search}

# Required query param — no default value
@app.get("/search")
def search(q: str):                # GET /search?q=python  (required)
    return {"query": q}

# Boolean query param — FastAPI accepts: true/false/1/0/on/off/yes/no
@app.get("/items/active")
def active_items(active: bool = True):
    return {"active_filter": active}
main.py

A parameter without a default (like q: str above) becomes required; omitting it returns a 422 error. Parameters with defaults are optional. You can mix path and query parameters freely in the same function.

6 · Request Bodies with Pydantic

When a client sends JSON in the request body (typically with POST, PUT, or PATCH), declare a Pydantic model as a function parameter. FastAPI reads the body, validates every field against the model, and passes a fully typed Python object to your function.

from fastapi import FastAPI
from pydantic import BaseModel, Field
from typing import Optional

app = FastAPI()

class Item(BaseModel):
    name: str
    description: Optional[str] = None
    price: float = Field(gt=0, description="Must be positive")
    in_stock: bool = True

class ItemUpdate(BaseModel):
    name: Optional[str] = None
    price: Optional[float] = Field(default=None, gt=0)
    in_stock: Optional[bool] = None

# POST — body is the Item model
@app.post("/items")
def create_item(item: Item):
    # item is fully validated and typed
    return {"received": item.model_dump()}

# PATCH — partial update with ItemUpdate
@app.patch("/items/{item_id}")
def update_item(item_id: int, update: ItemUpdate):
    return {"item_id": item_id, "changes": update.model_dump(exclude_none=True)}
main.py

FastAPI uses a simple inference rule to classify each parameter:

  • Declared in {braces} in the route path → path parameter
  • Type is a Pydantic BaseModelrequest body
  • Everything else → query parameter

An example JSON body that maps to the Item model above:

{
  "name": "Widget",
  "description": "A useful thing",
  "price": 9.99,
  "in_stock": true
}
request body (JSON)
Warning: Use Optional[T] = None for all fields in a PATCH / update model. If a field is absent, that means the client does not want to change it. A model where every field is required would force the client to resend data it does not intend to modify.

7 · Response Models & Status Codes

The response_model parameter on a path operation decorator tells FastAPI to filter and validate the output through a Pydantic model before sending it to the client. This is especially useful for stripping internal fields (passwords, tokens, internal IDs) from the response even if they are present in the object you return.

from fastapi import FastAPI, status
from pydantic import BaseModel
from typing import Optional, List

app = FastAPI()

class ItemIn(BaseModel):
    name: str
    price: float
    secret_token: str       # should NOT appear in response

class ItemOut(BaseModel):
    id: int
    name: str
    price: float
    # secret_token excluded

# response_model filters and validates the output shape
@app.post(
    "/items",
    response_model=ItemOut,
    status_code=status.HTTP_201_CREATED,
)
def create_item(item: ItemIn):
    # secret_token is stripped by response_model
    return ItemOut(id=42, name=item.name, price=item.price)

@app.get("/items", response_model=List[ItemOut])
def list_items():
    return [
        ItemOut(id=1, name="Widget", price=9.99),
        ItemOut(id=2, name="Gadget", price=24.99),
    ]

# Common status codes via fastapi.status
# status.HTTP_200_OK                    200
# status.HTTP_201_CREATED               201
# status.HTTP_204_NO_CONTENT            204
# status.HTTP_400_BAD_REQUEST           400
# status.HTTP_401_UNAUTHORIZED          401
# status.HTTP_403_FORBIDDEN             403
# status.HTTP_404_NOT_FOUND             404
# status.HTTP_422_UNPROCESSABLE_ENTITY  422
main.py

Common HTTP Status Codes

Code Constant Meaning Typical FastAPI use case
200 HTTP_200_OK OK Default for GET, PUT, PATCH success
201 HTTP_201_CREATED Created POST that creates a new resource
204 HTTP_204_NO_CONTENT No Content DELETE that returns no body
400 HTTP_400_BAD_REQUEST Bad Request Business-logic validation failure
401 HTTP_401_UNAUTHORIZED Unauthorized Missing or invalid authentication credentials
403 HTTP_403_FORBIDDEN Forbidden Authenticated but not authorised
404 HTTP_404_NOT_FOUND Not Found Resource with given ID does not exist
422 HTTP_422_UNPROCESSABLE_ENTITY Unprocessable Entity Automatic Pydantic / path-param validation failure

Always import status codes from fastapi.status rather than hard-coding integers — it makes intent clear, enables IDE autocompletion, and prevents typos.

APIRouter — Organising Routes

As your app grows, defining every route in main.py becomes unmanageable. APIRouter lets you split routes into separate modules and mount them with a prefix and shared tags.

# routers/items.py
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Optional, List

router = APIRouter(
    prefix="/items",          # prepended to every route in this file
    tags=["items"],           # groups routes in /docs
)

class Item(BaseModel):
    id: int
    name: str
    price: float

_db: list[Item] = [
    Item(id=1, name="Widget", price=9.99),
    Item(id=2, name="Gadget", price=24.99),
]

@router.get("/", response_model=List[Item])
def list_items():
    return _db

@router.get("/{item_id}", response_model=Item)
def get_item(item_id: int):
    for item in _db:
        if item.id == item_id:
            return item
    return None
routers/items.py
# routers/users.py
from fastapi import APIRouter

router = APIRouter(prefix="/users", tags=["users"])

@router.get("/")
def list_users():
    return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
routers/users.py
# main.py — wire everything together
from fastapi import FastAPI
from routers import items, users

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

app.include_router(items.router)
app.include_router(users.router)

@app.get("/health", tags=["meta"])
def health():
    return {"status": "ok"}
main.py

The resulting routes are:

MethodPathTag
GET/items/items
GET/items/{item_id}items
GET/users/users
GET/healthmeta
Recommended project layout for a small FastAPI app:
myapi/
├── main.py
├── routers/
│   ├── __init__.py
│   ├── items.py
│   └── users.py
├── models/
│   └── schemas.py        ← Pydantic models
├── dependencies.py       ← shared Depends() functions
└── pyproject.toml

HTTPException & Error Handling

Raise HTTPException to return a specific HTTP error response with a JSON body, instead of returning None or crashing.

from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
from typing import Optional

app = FastAPI()

fake_db: dict[int, dict] = {
    1: {"name": "Widget", "price": 9.99},
    2: {"name": "Gadget", "price": 24.99},
}

@app.get("/items/{item_id}")
def get_item(item_id: int):
    if item_id not in fake_db:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Item {item_id} not found",
        )
    return fake_db[item_id]

# ── Custom exception handler ──
from fastapi import Request
from fastapi.responses import JSONResponse

class InsufficientStockError(Exception):
    def __init__(self, item_id: int, available: int):
        self.item_id   = item_id
        self.available = available

@app.exception_handler(InsufficientStockError)
async def stock_handler(request: Request, exc: InsufficientStockError):
    return JSONResponse(
        status_code=409,
        content={
            "error":     "insufficient_stock",
            "item_id":   exc.item_id,
            "available": exc.available,
        },
    )
error_handling.py
Always raise HTTPException — never return None for missing resources. Returning None from a response_model-typed endpoint causes a serialisation error; raising 404 gives the client a clear, standard response.

Async Path Operations

FastAPI supports both sync and async path operation functions. Use async def when your handler does I/O (database queries, HTTP calls, file reads).

import asyncio
import httpx
from fastapi import FastAPI

app = FastAPI()

# Sync handler — fine for CPU-bound or blocking-free logic
@app.get("/sync")
def sync_route():
    return {"type": "sync"}

# Async handler — use for I/O: DB, external APIs, file ops
@app.get("/async")
async def async_route():
    async with httpx.AsyncClient() as client:
        resp = await client.get("https://httpbin.org/get")
    return resp.json()

# Concurrent I/O — gather multiple calls
@app.get("/combined")
async def combined():
    async with httpx.AsyncClient() as client:
        results = await asyncio.gather(
            client.get("https://httpbin.org/get"),
            client.get("https://httpbin.org/ip"),
        )
    return [r.json() for r in results]
async_routes.py
Rule of thumb: use async def when calling any awaitable (databases via asyncpg/SQLAlchemy async, Redis, external HTTP). Use plain def for CPU-heavy logic — FastAPI runs sync functions in a thread pool automatically, so they don't block the event loop.

Best Practices

  • Always use response_model — it documents the output shape, strips unexpected fields, and validates what you return.
  • Separate input and output models — an ItemIn (with a password field) and an ItemOut (without) prevents accidental data leaks.
  • Use status.HTTP_* constants over bare integers — status.HTTP_201_CREATED is self-documenting.
  • Organise with APIRouter from day one — refactoring a 500-line main.py later is painful.
  • Raise HTTPException, never return error dicts — standard error responses let clients handle errors reliably.
  • Use async def for I/O, def for CPU — mixing them incorrectly degrades throughput.
  • Set title, description, version on FastAPI() — they populate the auto-generated docs.
  • Add tags to routers and endpoints — the Swagger UI groups routes by tag, making docs navigable.

Exercises

Exercise 1 — Books CRUD API

Build a complete in-memory CRUD API for a Book resource:

  • Define BookIn (name, author, year, price) and BookOut (same + id) Pydantic models.
  • Store books in a module-level dict[int, BookOut].
  • GET /books — list all, with optional ?author= query filter.
  • POST /books → 201 — create; auto-assign incrementing id.
  • GET /books/{book_id} → 200 or 404.
  • PUT /books/{book_id} → 200 or 404 — full replace.
  • DELETE /books/{book_id} → 204 or 404.
💡 Hint
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel
from typing import Optional

app = FastAPI()

class BookIn(BaseModel):
    name: str
    author: str
    year: int
    price: float

class BookOut(BookIn):
    id: int

_books: dict[int, BookOut] = {}
_next_id = 1

@app.get("/books", response_model=list[BookOut])
def list_books(author: Optional[str] = None):
    books = list(_books.values())
    if author:
        books = [b for b in books if b.author.lower() == author.lower()]
    return books

@app.post("/books", response_model=BookOut, status_code=status.HTTP_201_CREATED)
def create_book(book: BookIn):
    global _next_id
    out = BookOut(id=_next_id, **book.model_dump())
    _books[_next_id] = out
    _next_id += 1
    return out

@app.get("/books/{book_id}", response_model=BookOut)
def get_book(book_id: int):
    if book_id not in _books:
        raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Book not found")
    return _books[book_id]

@app.delete("/books/{book_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_book(book_id: int):
    if book_id not in _books:
        raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Book not found")
    del _books[book_id]

Exercise 2 — Router Refactor & Tags

Split the Books API into a proper multi-module project:

  • Create routers/books.py — move all /books routes there using APIRouter(prefix="/books", tags=["books"]).
  • Create routers/health.py — one GET /health route returning {"status": "ok", "version": "1.0.0"}.
  • Wire both routers in main.py with app.include_router().
  • Start the server and verify /docs shows two tagged groups: books and health.
💡 Hint — main.py
from fastapi import FastAPI
from routers import books, health

app = FastAPI(title="Books API", version="1.0.0")
app.include_router(books.router)
app.include_router(health.router)

Exercise 3 — Async External API Proxy

Build an async FastAPI route that proxies a public REST API:

  • Install httpx: pip install httpx.
  • GET /pokemon/{name} — fetches https://pokeapi.co/api/v2/pokemon/{name} and returns a trimmed response with just name, height, weight, and a list of ability names.
  • Use async def and httpx.AsyncClient.
  • Return 404 with a clear message if the Pokémon is not found (PokeAPI returns 404).
  • Define a PokemonOut Pydantic model for the response shape.
💡 Hint
import httpx
from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel

app = FastAPI()

class PokemonOut(BaseModel):
    name: str
    height: int
    weight: int
    abilities: list[str]

@app.get("/pokemon/{name}", response_model=PokemonOut)
async def get_pokemon(name: str):
    async with httpx.AsyncClient() as client:
        resp = await client.get(f"https://pokeapi.co/api/v2/pokemon/{name.lower()}")
    if resp.status_code == 404:
        raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"Pokemon '{name}' not found")
    data = resp.json()
    return PokemonOut(
        name=data["name"],
        height=data["height"],
        weight=data["weight"],
        abilities=[a["ability"]["name"] for a in data["abilities"]],
    )