datarekha

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.

10 min read Intermediate Python Lesson 37 of 41

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:

JSON bodyrequest invalidate ItemIn422 if invalidcreate_item()your codefilter ItemOutresponse_model201 JSONresponse out

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_model filters the output to the public schema (strips internal fields).
  • Use async def for endpoints that await I/O; sync routes run safely in a threadpool.
  • Depends injects shared concerns (auth, DB) once and resolves them per request.

Practice

Quick check

0/3
Q1How does FastAPI know whether a parameter is a query string or a body?
Q2What does `response_model=ItemOut` enforce on the way out?
Q3Why use `Depends(current_user)` instead of importing and calling `current_user()` directly?

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.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Related lessons

Explore further

Skip to content