A route's job is to turn an HTTP request into typed Python arguments. FastAPI does that from the function signature alone — where a value comes from, what type it should be, whether it is required, and what makes it invalid are all declared in the parameter list. This lesson covers the handful of declarations that between them handle almost everything.
Where each parameter comes from
FastAPI decides what a parameter is by a short set of rules, applied in order:
- The name matches a
{placeholder}in the path → path parameter. - The type is a pydantic model → request body.
- The type is a special one FastAPI knows —
Request,BackgroundTasks,UploadFile→ that thing. - It has a
Depends(...)→ dependency (lesson 5). - Anything else → query parameter.
That inference is why the simple cases need no ceremony, and why the surprising cases surprise people. Rule 5 in particular is a catch-all: misspell a path placeholder and your path parameter silently becomes a required query parameter, which shows up as a 422 complaining about something you never wrote.
Path parameters
A path parameter identifies which resource. It is never optional — it is part of the URL — and its annotation does real conversion:
@router.get("/{public_id}", response_model=PropertyResponse)
def get_property(public_id: UUID, db: DbSession) -> PropertyResponse:
return PropertyResponse.model_validate(PropertyService(db).get_for_public(public_id))That is StayHub's listing endpoint. public_id: UUID means the function receives a
real uuid.UUID, and anything that is not a UUID never reaches it:
curl -s localhost:8000/api/v1/properties/not-a-uuid | head -c 130{"detail":[{"type":"uuid_parsing","loc":["path","public_id"],
"msg":"Input should be a valid UUID, invalid character: expected an optional
prefix of `urn:uuid:` followed by [0-9a-fA-F-], found `n` at 1"}]}Worth noticing that the API exposes a UUID rather than the database's integer primary key.
StayHub stores both: a BIGINT for fast internal joins and a public_id
UUID that is the only id ever sent outside the process. A sequential integer in a URL tells the
world how many rows you have and invites walking /properties/1,
/properties/2.
The ordering trap
Routes are matched in declaration order, first match wins. That single fact causes a bug almost everyone hits once. Here is the real order in StayHub, abbreviated:
router = APIRouter(prefix="/properties", tags=["properties"])
@router.get("/amenities", response_model=list[AmenityResponse])
def list_amenities(db: DbSession) -> list[AmenityResponse]:
...
@router.get("/mine", response_model=list[PropertyResponse])
def my_listings(host: HostUser, db: DbSession) -> list[PropertyResponse]:
...
@router.get("/{public_id}", response_model=PropertyResponse)
def get_property(public_id: UUID, db: DbSession) -> PropertyResponse:/amenities and /mine are declared before
/{public_id}, and that is load-bearing. Move the parameterised one up and it matches
first for every request, so GET /properties/mine is read as "the property whose
public_id is mine". The UUID conversion then fails and the endpoint
answers 422 — for a route that exists and works.
Rule: declare literal paths before parameterised ones at the same level. The symptom when you get it wrong is a validation error on the wrong endpoint, which sends people looking in entirely the wrong file.
Query parameters
Query parameters are for filtering, sorting and paginating a collection — the modifiers on a request, not its identity. Anything with a default is optional:
@router.get("", response_model=SearchResponse)
def search(
q: str | None = Query(default=None, max_length=200),
check_in: date | None = Query(default=None, alias="checkIn"),
check_out: date | None = Query(default=None, alias="checkOut"),
guests: int | None = Query(default=None, ge=1, le=50),
min_price: Decimal | None = Query(default=None, ge=0, alias="minPrice"),
max_price: Decimal | None = Query(default=None, ge=0, alias="maxPrice"),
property_type: str | None = Query(default=None, alias="propertyType"),
room_type: str | None = Query(default=None, alias="roomType"),
amenities: list[str] = Query(default=[]),
page: int = Query(default=1, ge=1, le=100),
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"),
sort: str = Query(default="relevance", pattern="^(relevance|price_asc|price_desc|rating)$"),
) -> SearchResponse:That is StayHub's search endpoint, and nearly every query-parameter feature is in it.
alias is the snake_case/camelCase boundary. Python wants
page_size; a JavaScript client sends pageSize. The alias is what the wire
uses, the parameter name is what the function uses, and neither side has to bend. Without it every
frontend call would carry page_size, which reads wrong in TypeScript.
Constraints are enforcement, not documentation. ge=1, le=100 on
page_size is what stops a caller asking for a million rows. Pagination without an
upper bound protects nothing — ?pageSize=1000000 is a full table scan
serialised into memory:
curl -s -o /dev/null -w '%{http_code}\n' 'localhost:8000/api/v1/search?pageSize=100000'422pattern handles small closed sets. sort accepts four
values and a regex is enough; it also lands in the OpenAPI schema so the docs list them. For a
larger or more meaningful set, use an Enum — FastAPI renders it as a dropdown and
you get a Python type rather than a string.
amenities: list[str] is a repeated parameter, not a comma-joined
string:
curl -s 'localhost:8000/api/v1/search?amenities=wifi&amenities=parking&pageSize=1' \
| head -c 80Repeating the key needs no parsing and no decision about what to do with a value containing a comma. Splitting a string is the version that breaks the first time somebody has an amenity called "Washer, dryer".
Say what a parameter does not do
Two parameters in that signature are accepted and then ignored, and the endpoint says so outright:
"""Search published listings.
⚠️ `checkIn` / `checkOut` are accepted but do NOT filter results yet. Date availability lives
in Postgres (the bookings table), not in the index, so filtering on it here would mean either
denormalising every booking into the document or a second query per hit. The listing page
checks availability properly. Saying so out loud beats a filter that quietly does nothing.
"""That docstring is rendered into the public documentation. A parameter that looks like it filters and does not is a bug report waiting to happen; admitting it costs three lines and saves an afternoon.
Annotated, and why it is the form to learn
There are two ways to attach metadata to a parameter. The older one puts it in the default slot:
def search(q: str | None = Query(default=None, max_length=200)): ...The newer one puts it in the type:
def search(q: Annotated[str | None, Query(max_length=200)] = None): ...Both work. The second is better for a reason that only shows up outside FastAPI: in the first
form, the function's default value is a Query object. Call it directly from a
test or another function and q is not None, it is a
Query instance, and the failure is baffling. With Annotated the default is
a real None and the function behaves like an ordinary Python function.
Annotated also composes. Because it is just a type, it can be given a name and
reused — which is how StayHub's dependencies read as one word each:
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[User, Depends(get_current_user)]
HostUser = Annotated[User, Depends(require_host)]Those three names then appear in every signature in the application. Lesson 5 is entirely about what they do.
Constraining a path parameter
Path() is Query()'s counterpart and takes the same arguments. It is
worth using whenever the identifier has a range:
from fastapi import Path
from typing import Annotated
@app.get("/pages/{page_number}")
def get_page(page_number: Annotated[int, Path(ge=1, le=10_000)]):
...Without the bound, /pages/-5 and /pages/999999999 both reach your
function and become somebody else's problem one layer down. With it they are 422s that never touch
your code.
Enums beat regexes for closed sets
The sort parameter above uses a pattern, which is fine for four values.
Once a set has meaning in the domain, an Enum is better:
class BookingStatus(StrEnum):
PENDING = "PENDING" # dates held, not yet paid
CONFIRMED = "CONFIRMED" # paid
CANCELLED = "CANCELLED"
COMPLETED = "COMPLETED"Used as a parameter type, that gives you three things a regex does not: the function receives a
real BookingStatus rather than a string, the OpenAPI schema lists the allowed values so
/docs renders a dropdown, and adding a value is one line in one place. StayHub's admin
listing takes exactly this:
@router.get("/bookings", response_model=Page[AdminBookingRow])
def list_bookings(
_: AdminUser,
db: DbSession,
page: PageQuery,
status_filter: BookingStatus | None = Query(default=None, alias="status"),
) -> Page[AdminBookingRow]:curl -s -o /dev/null -w '%{http_code}
' -H "Authorization: Bearer $TOKEN" 'localhost:8000/api/v1/admin/bookings?status=NOPE'422Note alias="status" again, for a different reason this time: status is
already the name of the FastAPI module imported at the top of that file, so the parameter is called
status_filter in Python and status on the wire.
Request bodies
A parameter annotated with a pydantic model is the body. There is no decorator and no
Body() needed:
@router.post("", response_model=BookingResponse, status_code=status.HTTP_201_CREATED)
def create_booking(
payload: BookingCreateRequest, user: CurrentUser, db: DbSession, background: BackgroundTasks
) -> BookingResponse:Four parameters, four different sources: payload is the JSON body,
user and db are dependencies, and background is a framework
object. The signature is the whole wiring.
The important design point is what BookingCreateRequest does not contain:
class BookingCreateRequest(QuoteRequest):
"""Same fields as a quote — deliberately. The client never sends a price.
⚠️ There is no `total` here and there never should be. The server recomputes every figure;
a client-sent price is an invitation to book a $400 stay for $4.
"""If a field is not on the request model, FastAPI ignores it when it arrives. Sending
{"total": 4.00} does not raise and does not set anything — the field simply does
not exist. Leaving prices off the request model is therefore not a validation rule you might forget
to check; it is a shape that cannot express the attack.
More than one body parameter
Declare two models and FastAPI nests them under their parameter names rather than merging them:
@app.post("/listings/{listing_id}/review")
def review(listing_id: int, rating: Rating, author: Author):
...{"rating": {"score": 5}, "author": {"name": "Sam"}}That is occasionally what you want and usually a sign the two should be one model. The related
surprise is the single-model case: one body parameter is not nested, so the JSON is the
model's fields at the top level. If you want the nested shape with only one model, ask for it with
Body(embed=True).
A scalar in the body needs Body() explicitly, because rule 5 would otherwise make
it a query parameter:
@app.post("/listings/{listing_id}/note")
def add_note(listing_id: int, note: Annotated[str, Body()]):
...Form data
HTML form posts are application/x-www-form-urlencoded, not JSON, and need their own
marker:
from fastapi import Form
@app.post("/login")
def login(username: Annotated[str, Form()], password: Annotated[str, Form()]):
...This requires python-multipart to be installed — and if it is not, the error
arrives at import time rather than when the endpoint is called, which is the friendlier of
the two options. The same package handles file uploads, covered in lesson 10.
You cannot mix Form and a JSON body in one endpoint. A request has
one body and one encoding. StayHub takes JSON everywhere, including for login, because its clients
are React apps rather than browser forms.
Headers and cookies
Both work like query parameters with their own marker. Headers need the alias, because HTTP header names contain hyphens and Python identifiers cannot:
@router.post("/webhook", response_model=Message, include_in_schema=False)
async def stripe_webhook(
request: Request,
db: DbSession,
stripe_signature: str = Header(default="", alias="Stripe-Signature"),
) -> Message:That is StayHub's Stripe webhook. Three things in one signature are worth copying:
Header(alias=...) for the hyphenated name; request: Request to reach the
raw body, because a signature must be verified against the exact bytes Stripe sent
rather than a re-serialised parse of them; and include_in_schema=False to keep the
endpoint out of the public documentation.
Cookies are the same shape with Cookie(...). StayHub uses none — it is a
token API consumed by React apps, so credentials travel in the Authorization header.
Status codes
The default is 200 for everything, which is wrong often enough to be worth fixing deliberately. Set it on the decorator:
@router.post("", response_model=PropertyResponse, status_code=status.HTTP_201_CREATED)
def create_property(
payload: PropertyCreateRequest, host: HostUser, db: DbSession
) -> PropertyResponse:
"""Create a listing. It starts as a DRAFT — publishing is a separate call."""
return PropertyResponse.model_validate(PropertyService(db).create(host, payload))Use the status constants rather than the integers. HTTP_201_CREATED
survives being read aloud in a review in a way that 201 does not, and a typo in a
constant is an AttributeError rather than a wrong response.
The ones worth knowing:
| Code | When |
|---|---|
| 200 OK | a successful GET, PATCH, or action that returns the thing |
| 201 Created | a POST that created a resource |
| 204 No Content | success with nothing to say — often a DELETE |
| 400 Bad Request | the request is wrong in a way you detected |
| 401 Unauthorized | not signed in — "unauthenticated", despite the name |
| 403 Forbidden | signed in, not allowed |
| 404 Not Found | no such thing — or deliberately, someone else's thing |
| 409 Conflict | well formed, but the world says no — those dates are taken |
| 422 Unprocessable | failed validation — FastAPI's own default |
StayHub returns 404 rather than 403 for a resource somebody else owns. A 403 confirms that the id exists, and on a guessable identifier that is a slow enumeration of the table. The exception is the admin API, which returns 403, because its existence is documented and not worth hiding.
409 is the one people miss. "Those dates are no longer available" is not a malformed request and not a permissions problem — it is a valid request the current state of the world refuses. Lesson 8 covers how StayHub turns a database constraint violation into one.
Reaching for the request itself
Occasionally the declarative approach is not enough and you need the raw request — the unparsed body, the client address, an arbitrary header. Ask for it by type:
payload = await request.body()
try:
event = PaymentService(db).handle_webhook(payload, stripe_signature)
except ApiException:
raiseawait request.body() returns the exact bytes received. For the Stripe webhook that
is not a convenience but a requirement: the signature covers the literal payload, so parsing it to
JSON and re-serialising it produces a different byte string and every signature check fails.
Note the route must be async def to await that. Reaching for
request is a small escape hatch and should feel like one — if several endpoints
need it, that is usually a dependency waiting to be written.
What happens to what you return
The other half of a route is the response, and FastAPI is doing more than
json.dumps on the way out.
Return a dict or a list and it is encoded directly. Return a pydantic
model and it is serialised by that model. Return something neither — a SQLAlchemy row, a
Decimal, a datetime, a UUID — and
jsonable_encoder converts it first. That last part is why this works at all:
@router.get("/mine", response_model=list[PropertyResponse])
def my_listings(host: HostUser, db: DbSession) -> list[PropertyResponse]:
return [PropertyResponse.model_validate(p) for p in PropertyService(db).list_mine(host)]Decimal("189.00") has no JSON equivalent. FastAPI renders it as the string
"189.00" rather than the float 189.0, and that is the correct choice for
money — a float cannot hold 0.1 + 0.2 exactly and a booking total is the last
place you want that. Clients parse it as a decimal string. It surprises people expecting a number,
so it is worth knowing before a frontend does arithmetic on it.
Two escape hatches. Returning a Response (or JSONResponse) directly
skips response_model and the encoder entirely — useful when you need exact
control over the bytes or the headers, and a trap if you expected filtering to still happen. And
status_code can be set per-call rather than per-route by taking a
Response parameter and assigning to it, which is how one endpoint answers 200 or 201
depending on whether it created something.
The bigger topic — what response_model hides, and why it is a security
boundary rather than a formatting convenience — is lesson 7.
Reading a 422
Validation errors are the responses you will see most while building, so it pays to be fluent in them. Every entry has the same three fields:
{"detail":[{"type":"greater_than_equal","loc":["body","guests"],
"msg":"Input should be greater than or equal to 1","input":0,
"ctx":{"ge":1}}]}loc is the path to the offending value and its first element names the
source — "body", "query", "path",
"header", "cookie". That first element is the fastest debugging tool in
the framework. If you expected a path parameter and loc says
["query", "listing_id"], the placeholder in your route string does not match the
parameter name — rule 5 from the top of this lesson quietly reclassified it.
For nested models loc keeps going: ["body", "images", 2, "url"] is the
third image's URL. That structure is machine-readable, which is the point — lesson 8 flattens
it into field → message so a form can render each error next to its input.
Trailing slashes
One small behaviour that causes confusion: by default, /properties and
/properties/ are different routes, and requesting the wrong one gets a
307 redirect to the right one. Most HTTP clients follow it silently, so it usually
works — but a redirect on a POST can drop the body in some clients, and a redirect that
crosses from HTTPS to HTTP behind a misconfigured proxy is worse.
Pick one convention and declare routes that way. StayHub declares the collection endpoint as
"" rather than "/", so with the router prefix the URL is
/api/v1/properties with no trailing slash, matching every other endpoint.
Splitting it up with APIRouter
A single main.py stops working somewhere around fifteen endpoints.
APIRouter is a router you attach endpoints to and mount later:
api_router = APIRouter()
api_router.include_router(auth.router)
api_router.include_router(properties.router)
api_router.include_router(bookings.router)
api_router.include_router(payments.router)
api_router.include_router(search.router)
api_router.include_router(uploads.router)
api_router.include_router(admin.router)Each module owns its own prefix and tags, so a file is self-describing and moving it changes nothing else. Then the whole tree mounts once, under a version:
app.include_router(api_router, prefix=settings.api_v1_prefix)Versioning the prefix from day one is nearly free; retrofitting it once clients exist is not.
/api/v1 costs you one line now and buys the ability to run /api/v2
alongside it later without breaking anybody.
Prefixes compose, which is worth knowing before it confuses you: the router declares
prefix="/properties", the mount adds /api/v1, and the endpoint declares
"/{public_id}" — so the URL is
/api/v1/properties/{public_id}. A prefix must start with / and must not
end with one, and FastAPI raises at import time if you get that wrong rather than serving a broken
path.
Declaring something once for a whole router
APIRouter takes most of what a decorator takes, applied to every endpoint under it.
StayHub's admin router is the clearest case — every endpoint requires staff, and rather than
repeating that it could be declared once:
router = APIRouter(prefix="/admin", tags=["admin"])# the same router, with the requirement hoisted
router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(require_admin)],
responses={403: {"model": ErrorBody, "description": "Staff access only"}},
)dependencies=[...] runs for every endpoint but injects nothing — it is for
checks whose return value you do not need. responses={...} documents a status
code the endpoint can produce that FastAPI cannot infer.
StayHub uses the first form and repeats _: AdminUser in each signature instead. That
is a deliberate trade: the requirement is then visible in the function you are reading rather than
forty lines away at the top of the file, which matters more for a security rule than the repetition
costs. Hoisting is the better call when the router is large or the dependency is uncontroversial
— a rate limiter, a request logger.
The short version
| You want | Declare |
|---|---|
| Which resource | {id} in the path, id: UUID in the signature |
| A filter or a page | a plain parameter with a default |
| Bounds on either | Annotated[int, Query(ge=1, le=100)] |
| A different name on the wire | alias="pageSize" |
| A closed set of values | an Enum as the type |
| Repeated values | list[str] |
| Data being created | a pydantic model parameter |
| A hyphenated header | Header(alias="Stripe-Signature") |
| The raw bytes | request: Request and await request.body() |
| A created resource | status_code=status.HTTP_201_CREATED |
Next: pydantic models and validation — the layer doing the actual work underneath every signature in this lesson, including validators, computed fields, and keeping the model you store separate from the model you return.