FastAPI – Routes, Parameters and Status Codes

September 5, 202515 min readUpdated 8/23/2026

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:

  1. The name matches a {placeholder} in the path → path parameter.
  2. The type is a pydantic model → request body.
  3. The type is a special one FastAPI knows — Request, BackgroundTasks, UploadFile → that thing.
  4. It has a Depends(...)dependency (lesson 5).
  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'
422

pattern 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 80

Repeating 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'
422

Note 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:

CodeWhen
200 OKa successful GET, PATCH, or action that returns the thing
201 Createda POST that created a resource
204 No Contentsuccess with nothing to say — often a DELETE
400 Bad Requestthe request is wrong in a way you detected
401 Unauthorizednot signed in — "unauthenticated", despite the name
403 Forbiddensigned in, not allowed
404 Not Foundno such thing — or deliberately, someone else's thing
409 Conflictwell formed, but the world says no — those dates are taken
422 Unprocessablefailed 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:
        raise

await 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 wantDeclare
Which resource{id} in the path, id: UUID in the signature
A filter or a pagea plain parameter with a default
Bounds on eitherAnnotated[int, Query(ge=1, le=100)]
A different name on the wirealias="pageSize"
A closed set of valuesan Enum as the type
Repeated valueslist[str]
Data being createda pydantic model parameter
A hyphenated headerHeader(alias="Stripe-Signature")
The raw bytesrequest: Request and await request.body()
A created resourcestatus_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.