Index Ai Academy
Phase 8 · APIs · Web apps

APIs with FastAPI

Typed routes, pydantic models, automatic validation and docs, tested with httpx.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

FastAPI is one of the most used Python web frameworks, and the usual choice for serving machine-learning models. Its central idea is that the type hints you already write are enough: from limit: int it knows to read a number from the query string, convert it, reject "abc" with a clear error, and list it in the API's documentation. You built that machinery by hand in the validation lesson; here a framework does it for you, and you learn where its defaults need your judgment.

What you'll be able to do

  • Build JSON API endpoints whose type hints convert and check every input
  • Describe request and response bodies with pydantic models and field constraints
  • Answer errors with HTTPException and share checks with dependencies
  • Test a FastAPI app with httpx, and know how it runs and documents itself on a server

What it covers

  1. An API from type hints
  2. Bodies with pydantic models
  3. Errors and dependencies
  4. async def or def?
  5. Worked example: a response model that keeps secrets in

An API from type hints

A FastAPI app looks much like a Flask app, with one difference that matters: the endpoint's parameters say what the request must contain. A name that appears in the path, like {slug}, comes from the path; any other simple parameter comes from the query string. Return a dictionary or a list, and FastAPI sends it as JSON.

import asyncio
import httpx
from fastapi import FastAPI

api = FastAPI()
TRAILS = {"red-mesa": {"name": "Red Mesa ridge", "km": 11.0}, "fern-hollow": {"name": "Fern Hollow loop", "km": 6.0}}

@api.get("/trails/{slug}")
def trail(slug: str):
    return TRAILS.get(slug, {})

@api.get("/trails")
def search(min_km: float = 0, limit: int = 10):
    found = [t for t in TRAILS.values() if t["km"] >= min_km]
    return found[:limit]

async def main():
    transport = httpx.ASGITransport(app=api)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        for url in ["/trails/red-mesa", "/trails?min_km=8", "/trails?limit=abc"]:
            reply = await client.get(url)
            print(url, reply.status_code, reply.json())

asyncio.run(main())

min_km=8 arrived as text and reached the function as the float 8.0. limit=abc never reached the function at all: FastAPI answered 422 Unprocessable Content with a description of exactly which input was wrong and why.

To call the app, the cell uses httpx, an HTTP client like requests, with a transport that hands each request straight to the app instead of the network. In a normal project you'd use FastAPI's TestClient, which wraps the same thing but runs the app in a background thread, and browsers can't start threads.

Note: asyncio.run needs a current browser (one with WebAssembly JSPI, like recent Chrome, Edge and Safari). If a cell here stops with an error about asyncio, run it on your own computer or update the browser.

Quick check
What does this print?
import asyncio, httpx
from fastapi import FastAPI
api = FastAPI()
@api.get("/add")
def add(a: int, b: int = 1):
    return a + b
async def main():
    async with httpx.AsyncClient(transport=httpx.ASGITransport(app=api), base_url="http://t") as c:
        print((await c.get("/add?a=2")).json(), (await c.get("/add?b=5")).status_code)
asyncio.run(main())
  1. 3 422
  2. 3 404
  3. 3 200
  4. 21 422
Show the answer

3 422

a="2" becomes the int 2, and b defaults to 1, so 3. A parameter without a default is required: leaving out a gives 422.

Bodies with pydantic models

A POST usually sends a JSON body. Describe its shape with a pydantic model: a class whose annotated fields say the type of each value, plus constraints with Field. Use the model as a parameter's type, and FastAPI reads the body, converts it and validates it, all before your function runs.

import asyncio
from datetime import date
import httpx
from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator

TODAY = date(2025, 6, 30)

class HikeIn(BaseModel):
    trail: str = Field(min_length=1, max_length=80)
    day: date
    km: float = Field(gt=0, le=100)

    @field_validator("day")
    @classmethod
    def not_in_future(cls, value):
        if value > TODAY:
            raise ValueError("can't be in the future")
        return value

class HikeOut(HikeIn):
    id: int

api = FastAPI()
HIKES = {}

@api.post("/hikes", status_code=201)
def add_hike(hike: HikeIn) -> HikeOut:
    new = HikeOut(id=len(HIKES) + 1, **hike.model_dump())
    HIKES[new.id] = new
    return new

async def main():
    async with httpx.AsyncClient(transport=httpx.ASGITransport(app=api), base_url="http://test") as client:
        good = await client.post("/hikes", json={"trail": "Red Mesa", "day": "2025-06-14", "km": "11.5"})
        print(good.status_code, good.json())
        bad = await client.post("/hikes", json={"trail": "", "day": "2025-07-14", "km": -2})
        print(bad.status_code)
        for problem in bad.json()["detail"]:
            print(" ", problem["loc"], problem["msg"])

asyncio.run(main())

Every problem is reported at once, each with its location (["body", "km"]) and a message, which is far more useful to the developer calling your API than a bare "invalid request". status_code=201 makes a successful POST answer 201 Created.

Rules that Field can't express, like "not after today", go in a method marked @field_validator. It receives the already converted value (here a real date, not text), raises ValueError with a short message when the value breaks the rule, and returns the value otherwise. pydantic adds that message to the same list of problems, so the caller sees one consistent kind of error, whichever rule failed.

The return annotation -> HikeOut is the response model: FastAPI converts the result to that shape and sends only its fields. That's an important safety net. If a database row has a password_hash column and the response model doesn't, the hash can never leak into a reply, even if someone returns the whole row by mistake.

Try it now

Choosing the status code is part of designing an API: clients decide what to do from it.

Errors and dependencies

When an endpoint finds a problem that validation can't, it raises HTTPException. FastAPI turns it into a JSON error reply with the status you choose:

from fastapi import HTTPException

@api.get("/hikes/{hike_id}")
def get_hike(hike_id: int) -> HikeOut:
    if hike_id not in HIKES:
        raise HTTPException(status_code=404, detail="Hike not found")
    return HIKES[hike_id]

Checks that many endpoints share, like "is there a valid API key?" or "read the page and limit", go in a dependency: a function whose parameters FastAPI fills from the request, just like an endpoint's. Depends(...) asks for its result:

from fastapi import Depends, Header, Query

def require_key(x_api_key: str | None = Header(default=None)):
    if x_api_key != API_KEY:
        raise HTTPException(status_code=401, detail="Missing or wrong API key")

def paging(limit: int = Query(10, ge=1, le=50), offset: int = Query(0, ge=0)):
    return {"limit": limit, "offset": offset}

@api.delete("/hikes/{hike_id}", status_code=204, dependencies=[Depends(require_key)])
def delete_hike(hike_id: int): ...

@api.get("/hikes")
def list_hikes(page: dict = Depends(paging)): ...

Header() reads a request header (the parameter x_api_key matches the header x-api-key), and Query(10, ge=1, le=50) adds limits to a query parameter. Dependencies can depend on other dependencies, which is how real apps build "the current user" from "the token" from "the header", each piece tested on its own.

async def or def?

FastAPI accepts both. An async def endpoint runs on the event loop, so while it awaits something slow, like a call to another service with an async client, the server handles other requests. A plain def endpoint runs in a worker thread, so blocking code inside it (a regular database driver, a big file read) doesn't freeze everyone else. The rule of thumb: use async def when everything slow in the endpoint is awaited; otherwise use def. Never call blocking code from an async def endpoint. The async clients lesson goes deeper.

(The academy has no threads, so here plain def endpoints run directly, one request at a time. Your code doesn't need to change.)

Documentation for free

From the routes, parameters and models, FastAPI writes an OpenAPI description of the whole API, as the OpenAPI lesson did by hand. api.openapi() returns it as a dictionary, and on a server the pages /docs and /redoc show it as interactive documentation where people can try every endpoint.

On your own computer, put the app in main.py, pip install "fastapi[standard]", and start a development server with fastapi dev main.py. In production, a server such as Uvicorn runs it, usually inside a container, as the containers lesson shows.

Worked example

Worked example: a response model that keeps secrets in

A sign-up endpoint receives a password, stores a hash, and replies with the account. The response model decides what leaves the server.

import asyncio, hashlib, secrets
import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

class SignUp(BaseModel):
    email: str = Field(pattern=r"^[^@\s]+@[^@\s]+$")
    name: str = Field(min_length=1)
    password: str = Field(min_length=12)

class Account(BaseModel):
    id: int
    email: str
    name: str

api = FastAPI()
ROWS = {}

@api.post("/accounts", status_code=201)
def sign_up(form: SignUp) -> Account:
    if any(row["email"] == form.email for row in ROWS.values()):
        raise HTTPException(status_code=409, detail="That email is already registered")
    salt = secrets.token_hex(8)
    row = {"id": len(ROWS) + 1, "email": form.email, "name": form.name, "salt": salt,
           "password_hash": hashlib.sha256((salt + form.password).encode()).hexdigest()}
    ROWS[row["id"]] = row
    return row  # the whole row, by mistake: the response model filters it

async def main():
    async with httpx.AsyncClient(transport=httpx.ASGITransport(app=api), base_url="http://test") as client:
        body = {"email": "ada@example.com", "name": "Ada", "password": "a long trail passphrase"}
        first = await client.post("/accounts", json=body)
        print(first.status_code, first.json())
        again = await client.post("/accounts", json=body)
        print(again.status_code, again.json())

asyncio.run(main())
In the lesson you can change this and run it.

The endpoint returns the whole database row, salt and hash included, but the reply contains only id, email and name, because the return annotation says the answer is an Account. A second sign-up with the same email gets 409 Conflict, a status for requests that clash with the current state. (The hash here is SHA-256 only to keep the example short; the login lesson explains why real apps use bcrypt.)

Practice what you just read

  • 5 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

An endpoint is def search(q: str, limit: int = 10). A client calls /search?limit=5. What happens?

  1. 422: q has no default, so it's required
  2. It runs with q set to an empty string
  3. It runs with q set to None
  4. 404: the path doesn't match

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson