FastAPI
Type hints define your API. FastAPI turns a Python function into a validated, documented HTTP endpoint with one decorator — now the default for new Python web services.
What you'll learn
- Path, query, and body parameters inferred from type hints alone
- Request and response models with Pydantic, and response_model as an export filter
- Async endpoints, and dependency injection with Depends
Before you start
For a decade, Flask was the default Python web framework. Then FastAPI shipped on one idea — your type hints define the API — and quietly took the throne. New ML services, LLM gateways, internal microservices: FastAPI is now what people reach for first. The whole pattern fits in a sentence: a type-annotated function is an endpoint, complete with validation, JSON serialisation, and auto-generated OpenAPI docs. You write the function; FastAPI infers everything around it.
The minimum app
from fastapi import FastAPI
app = FastAPI(title="Items API")
@app.get("/")
def root():
return {"status": "ok"}
@app.get("/items/{item_id}")
def get_item(item_id: int): # int — FastAPI parses AND validates it
return {"item_id": item_id}
Request /items/42 and it works; request /items/abc and you get a 422 Unprocessable Entity — the status code for “well-formed request, failed validation” — with a structured error message, for free. That single int annotation is the spec. And once it is running, opening http://localhost:8000/docs gives you an interactive Swagger UI generated from the very same hints. That is the feature that won people over.
Path versus query versus body
FastAPI decides what each parameter is purely from the signature, by three rules: if the name appears in the path string it is a path parameter; if it is a primitive with a default it is a query parameter; and if it is a Pydantic model it is the request body.
@app.get("/items/{item_id}")
def get_item(
item_id: int, # in the path -> path parameter
detail: bool = False, # primitive with default -> ?detail=true
):
return {"item_id": item_id, "detail": detail}
That last rule — a Pydantic model becomes the JSON body — is where everything you learned about Pydantic plugs straight in.
Bodies — Pydantic all the way down
Drop a Pydantic model into the signature and you get a typed, validated JSON body with no extra code. The pair below also shows the most useful trick, response_model:
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
_db: dict[int, dict] = {}
_next_id = 1
class ItemIn(BaseModel):
name: str = Field(min_length=1, max_length=80)
price: float = Field(ge=0)
tags: list[str] = []
class ItemOut(ItemIn):
id: int
@app.post("/items", response_model=ItemOut, status_code=201)
def create_item(item: ItemIn) -> ItemOut:
global _next_id
rec = item.model_dump() | {"id": _next_id}
_db[_next_id] = rec
_next_id += 1
return ItemOut(**rec)
That one decorator runs an entire request pipeline, and you only wrote the middle of it:
The one piece worth dwelling on is response_model=ItemOut, the export filter. Even if your function accidentally returns an object carrying an internal field — a password_hash, say — declaring response_model=ItemOut strips anything not in that model before it reaches the client. It is how you enforce the public schema independently of whatever you happen to compute inside.
Async endpoints
If an endpoint awaits I/O — an HTTP call, a database query, a queue — make it async def, and one worker process can serve many concurrent requests instead of blocking on each:
import httpx
from fastapi import FastAPI
app = FastAPI()
@app.get("/weather/{city}")
async def weather(city: str):
async with httpx.AsyncClient() as client:
r = await client.get(f"https://api.example.com/weather/{city}")
return r.json()
Mixing def and async def is fine: FastAPI runs plain sync endpoints in a threadpool so a slow synchronous route does not stall the event loop. But the moment a route does real I/O, async def is the upgrade.
Dependency injection — Depends
Real apps have cross-cutting needs: a database connection, the current authenticated user, a feature-flag client. FastAPI injects them as parameters through Depends, so the wiring lives in one place rather than being copy-pasted into every handler:
from fastapi import FastAPI, Depends, HTTPException, Header
app = FastAPI()
def current_user(authorization: str = Header(...)):
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Bad auth")
return {"id": 1, "email": "aarav@datarekha.com"} # in prod: verify the JWT
@app.get("/me")
def me(user=Depends(current_user)):
return user
@app.get("/admin")
def admin(user=Depends(current_user)): # same dependency, reused — no copy-paste auth
return {"hello": user["email"]}
The dependency is just a function — and it can have its own dependencies, which FastAPI resolves as a graph. That is how endpoints stay small and testable: in a test you override current_user with a fake and the whole route follows.
A small CRUD-shaped app
Read this for the ratio — how few lines do real work, with validation, serialisation, error responses, and docs all inferred:
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel, Field
app = FastAPI(title="Notes API")
_db: dict[int, "Note"] = {}
_next_id = 1
class NoteIn(BaseModel):
title: str = Field(min_length=1, max_length=120)
body: str
class Note(NoteIn):
id: int
def get_or_404(note_id: int) -> Note:
if note_id not in _db:
raise HTTPException(404, "Note not found")
return _db[note_id]
@app.get("/notes")
def list_notes() -> list[Note]:
return list(_db.values())
@app.post("/notes", status_code=201)
def create_note(payload: NoteIn) -> Note:
global _next_id
note = Note(id=_next_id, **payload.model_dump())
_db[_next_id] = note
_next_id += 1
return note
@app.get("/notes/{note_id}")
def read_note(note: Note = Depends(get_or_404)) -> Note:
return note
@app.delete("/notes/{note_id}", status_code=204)
def delete_note(note: Note = Depends(get_or_404)):
del _db[note.id]
Notice that get_or_404 is written once and reused by both read_note and delete_note — the 404 logic does not repeat. That is the whole FastAPI feel: declare the types, lean on Depends, and let the framework infer the rest.
In one breath
- A type-annotated function is a validated, documented endpoint — types are the spec.
- Path name → path param; primitive-with-default → query param; Pydantic model → JSON body.
response_modelfilters the output to the public schema (strips internal fields).- Use
async deffor endpoints that await I/O; sync routes run safely in a threadpool. Dependsinjects shared concerns (auth, DB) once and resolves them per request.
Practice
Quick check
What’s next
You can now ship a typed HTTP API. The remaining lessons take the same model-driven approach into LLM territory — beginning with tokenization, the unit every model actually reads and every bill is counted in.