1. Path Operations, Parameters & Pydantic Models
@app.get, @app.post, @app.put,
@app.patch and @app.delete are all path operation
decorators — each registers a function against an HTTP method and a URL
pattern. FastAPI matches incoming requests against these patterns in the order
they're declared, which matters the moment you mix static and dynamic segments: a
route like /items/latest has to be declared before
/items/{item_id}, or the dynamic route matches first and
"latest" gets passed in where FastAPI expects an integer.
A path parameter like {item_id} is captured from the
URL itself and handed to your function as an argument with the same name. The type
hint on that argument isn't cosmetic here — FastAPI uses it to coerce and validate
the raw string from the URL before your code runs. A query parameter
is any function argument that isn't part of the path: give it a default value and
it becomes optional (?q=foo); leave it without one and the request is
rejected with 422 if it's missing.
from typing import Annotated
from fastapi import FastAPI, Path, Query
app = FastAPI()
@app.get("/items/{item_id}")
def get_item(
item_id: Annotated[int, Path(ge=1, description="The positive integer ID of the item")],
q: Annotated[str | None, Query(max_length=50)] = None,
):
result = {"item_id": item_id}
if q:
result["q"] = q
return result
Annotated[int, Path(ge=1, ...)] attaches a constraint directly to the
type hint: item_id must parse as an integer and be greater
than or equal to 1, or the request never reaches the function body — FastAPI
responds with 422 and a JSON body listing exactly which field failed
and why. Query(max_length=50) works the same way for the optional
q parameter. This is the pattern you'll use for every constrained
parameter from here on: Annotated[type, Query(...) | Path(...)], with
the default value (or its absence) controlling whether the parameter is required.
Path and query parameters are fine for scalars, but a request body — the JSON a
client POSTs — needs something richer than a dataclass. A Pydantic
BaseModel looks similar to the @dataclass from Week 1, but
it actually validates: constructing one from bad data raises a
ValidationError, and when that model is a route's parameter type,
FastAPI turns that error into a 422 response automatically, before your
function ever executes.
from pydantic import BaseModel, Field
class BookCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
author: str
year: int = Field(ge=1450, le=2100)
@app.post("/books")
def create_book(body: BookCreate):
return {"id": 1, **body.model_dump()}
Field(min_length=1, max_length=200) is the Pydantic equivalent of
Query()/Path() constraints, but for model fields —
posting a title over 200 characters, or a year of 1200, fails
validation before create_book runs. body.model_dump()
turns the validated model back into a plain dict, which is what you'll
reach for constantly once you're storing or re-shaping parsed request data.
FastAPI's default response for a failed validation is 422 Unprocessable Entity, not 400 Bad Request — the body was syntactically valid JSON, it just didn't match the shape the model requires. The response body includes a detail list with a loc (which field), msg (what went wrong) and type for every failing field at once, not just the first one.
2. Reusable Dependencies with Depends()
You could write a plain helper function — say, pagination(limit, offset)
— and call it directly inside every route that needs pagination. It would work, but
FastAPI would have no idea it exists: it couldn't validate its parameters against
your route's query string, document them in /docs, cache the result
within a request, or let you swap in a fake version during tests.
Depends() promotes that helper into something FastAPI actively manages
— a dependency — by declaring it as a parameter's default instead
of calling it yourself.
from typing import Annotated
from fastapi import Depends, FastAPI, Query
app = FastAPI()
def pagination(
limit: Annotated[int, Query(ge=1, le=100)] = 20,
offset: Annotated[int, Query(ge=0)] = 0,
):
return {"limit": limit, "offset": offset}
@app.get("/tasks")
def list_tasks(page: Annotated[dict, Depends(pagination)]):
return {"items": [], **page}
Reading this line by line: pagination is an ordinary function whose
own parameters — limit and offset — are themselves
constrained query parameters, exactly like the ones in the previous section. Wrapping
it in Depends(pagination) and using that as the type of
list_tasks's page parameter tells FastAPI: "before calling
list_tasks, call pagination with values pulled from this
same request's query string, and pass its return value in as page."
GET /tasks?limit=10&offset=20 resolves pagination to
{"limit": 10, "offset": 20} automatically — your route function never
touches the raw query string at all.
The genuinely useful part shows up once a dependency is needed more than once per
request. If two different parameters in the same route both declare
Depends(pagination) — directly, or indirectly through another
dependency — FastAPI calls pagination() exactly once
for that request and reuses the cached result everywhere it's needed, rather than
re-running it and potentially getting different values. You can opt out per
declaration with Depends(pagination, use_cache=False) if you genuinely
need a fresh call, but the default caching is what makes composing dependencies out
of smaller dependencies safe.
def get_sort_field(sort: str = "id") -> str:
allowed = {"id", "title", "created_at"}
return sort if sort in allowed else "id"
def list_query(
page: Annotated[dict, Depends(pagination)],
sort: Annotated[str, Depends(get_sort_field)],
):
return {**page, "sort": sort}
@app.get("/tasks")
def list_tasks(query: Annotated[dict, Depends(list_query)]):
return {"items": [], **query}
list_query is itself a dependency that depends on two smaller ones —
FastAPI resolves the whole graph before calling list_tasks, and if
pagination were also needed directly by list_tasks in
addition to through list_query, it still would only run once. This is
the shape real FastAPI codebases converge on: small, single-purpose dependencies
composed into larger ones, instead of one large dependency doing everything.
Because a dependency is just a callable FastAPI looks up rather than one you call yourself, tests can swap it out with app.dependency_overrides[pagination] = lambda: {"limit": 5, "offset": 0} — no monkeypatching, no real query string needed. You won't write that test until Week 8, but it's the payoff for using Depends() instead of a plain function call now.
3. Auto-Generated Docs: Swagger UI & ReDoc
Every FastAPI app exposes two documentation UIs for free, with zero extra code:
/docs renders Swagger UI, an interactive page where you
can expand any route, fill in parameters and actually fire a request against your
running server from the browser. /redoc renders ReDoc,
a cleaner read-only reference that's easier to skim or share with someone who just
wants to look up a shape. Both are generated from the exact same source: the JSON
schema FastAPI builds from your route's type hints, Query()/
Path() constraints, and Pydantic models, served raw at
/openapi.json.
That means the constraints from Section 1 — Field(min_length=1, max_length=200),
Path(ge=1) — aren't just runtime validation, they're documentation too:
a title field's max length shows up in Swagger UI without you writing a separate
sentence about it. Three decorator arguments let you sharpen the generated docs
further without touching the schema itself: summary (a short label),
description (longer prose, supports Markdown), and
response_model (the shape of a successful response).
from fastapi import HTTPException
from pydantic import BaseModel
class BookOut(BaseModel):
id: int
title: str
author: str
year: int
@app.get(
"/books/{book_id}",
response_model=BookOut,
summary="Fetch a single book",
description="Returns a book by its numeric ID, or 404 if no book with that ID exists.",
tags=["books"],
)
def get_book(book_id: int):
book = books_by_id.get(book_id)
if book is None:
raise HTTPException(status_code=404, detail="Book not found")
return book
response_model=BookOut does two things at once: it documents the
response shape in /docs and /redoc, and it actively filters
whatever get_book returns down to exactly BookOut's fields
— if your stored book dict ever grows an internal field like
internal_notes, this route still only ever serializes
id, title, author and year. That
filtering behavior becomes more important once real database rows show up later in
the course. tags=["books"] just groups related routes together under a
"books" heading in the generated docs.
With your server running, open /docs, expand POST /books, and click "Try it out." Submit a year of 1200 and read the 422 response body carefully — that detail array is exactly what your future API clients will parse to show a real user what they got wrong.
4. Hands-on Exercise
Build an in-memory books API
Put path/query parameters, your first Pydantic models, a reusable pagination dependency and documentation-shaping decorator arguments all to work in one small API.
Requirements:
- Define
BookCreate(title,author,yearwithFieldconstraints) andBookOut(same fields plusid) as separate Pydantic models. - Store books in a plain
dict[int, dict]seeded with 3–4 books at startup — no database yet, that's a later week. - Add
GET /booksusing thepaginationdependency from Section 2 (limit/offset), plus an optionalauthorquery parameter (Annotated[str | None, Query(max_length=100)]) that filters by a case-insensitive substring match. - Add
GET /books/{book_id}withbook_id: intconstrained byPath(ge=1), raisingHTTPException(status_code=404)when the ID isn't found. - Add
POST /booksaccepting aBookCreatebody, returning aBookOutviaresponse_modelwithstatus_code=201. - Give every route a
summaryanddescription, and confirm all three render correctly — including the constraint hints — in both/docsand/redoc.
Assign each seeded book's id yourself (an incrementing counter is fine) — don't reach for a real ID-generation strategy yet. And don't worry about a consistent error-response shape across every failure case; that's the centralized exception-handling topic coming in Week 3.
5. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why is Depends() better than calling a shared helper function directly inside each route?
Why is Depends() better than calling a shared helper function directly inside each route?
Wrapping a function in Depends() hands FastAPI the whole dependency, not just its return value — FastAPI resolves its parameters against the current request the same way it does for a route, documents it in the generated OpenAPI schema, caches its result so it only runs once per request even if needed by multiple parts of the dependency graph, and lets tests override it entirely via app.dependency_overrides without touching the route's code. A directly-called helper gets none of that; it's just a function call FastAPI can't see.
Q2
Your route declares item_id: int as a path parameter, and a client requests /items/abc. What happens?
Your route declares item_id: int as a path parameter, and a client requests /items/abc. What happens?
Your function body never runs. FastAPI tries to coerce the raw URL segment "abc" into an int before calling your route, fails, and responds with 422 Unprocessable Entity and a JSON detail array describing exactly which parameter failed and why. This is the same mechanism that validates Pydantic request bodies — it's just applied to a single scalar path parameter instead of a whole object.
Q3
What determines whether a query parameter is required or optional, and how does that interact with Query()'s constraints?
What determines whether a query parameter is required or optional, and how does that interact with Query()'s constraints?
It's purely about whether the parameter has a default value — q: Annotated[str | None, Query(max_length=50)] = None is optional because of the = None, not because of anything inside Query(). Query() only ever adds validation constraints (max_length, ge, a regex pattern, and so on) on top of whatever requiredness the default value already establishes. To make a constrained query parameter required, you give it no default — or explicitly write Query(...) with the ellipsis as a marker meaning "no default, this is required."
Q4
What's the practical difference between /docs and /redoc, and where does the data both of them render actually come from?
What's the practical difference between /docs and /redoc, and where does the data both of them render actually come from?
/docs (Swagger UI) is interactive — you can expand a route, fill in real parameter values and fire an actual request against your running app from the browser. /redoc (ReDoc) is a read-only reference page, generally easier to skim or link someone who just needs to look up a shape rather than test it live. Both are rendered client-side from the same raw JSON document, served by FastAPI at /openapi.json, which it builds automatically from your routes' type hints, Query()/Path() constraints, and Pydantic models — you never write that schema by hand.