05.02

Path, query, and body parameters

Lesson 02 of 9 · 4:25
skillmaxing
Python with AI
Lesson 02
Path, query, and body parameters
0:00 / 4:251x
Notes

An HTTP request carries input in three places. The path identifies a resource (/tickets/42). The query string filters or pages a collection (?status=open&limit=20). The body carries a document to create or update. FastAPI maps all three onto ordinary function parameters, and the rule for which is which is short enough to memorise: a parameter named in the path template is a path parameter; a parameter typed as a Pydantic model is the body; anything else is a query parameter.

Three places input can come from
Figure 1Three places input can come fromIdentity in the path, filters in the query, documents in the body.

Each kind has a helper (Path, Query, Body) that adds constraints and documentation, used with Annotated so the type and the rules stay together, exactly as Field did in the Pydantic lessons.

Path and query

from typing import Annotated, Literal
from fastapi import FastAPI, Path, Query

app = FastAPI()

Status = Literal["open", "pending", "closed"]

@app.get("/tickets/{ticket_id}")
async def get_ticket(
    ticket_id: Annotated[int, Path(gt=0, description="Ticket number")],
) -> Ticket:
    ...

@app.get("/tickets")
async def list_tickets(
    status: Status | None = None,
    q: Annotated[str | None, Query(max_length=80)] = None,
    tag: Annotated[list[str], Query()] = [],
    limit: Annotated[int, Query(ge=1, le=100)] = 20,
    offset: Annotated[int, Query(ge=0)] = 0,
) -> list[Ticket]:
    ...

ticket_id is required because it is in the path; a request to /tickets/0 fails the gt=0 check with a 422. In list_tickets, every parameter has a default, so every one is optional. status is validated against the Literal, limit is clamped to a range, and tag accepts repeated keys (?tag=billing&tag=export) because it is declared as a list with Query(). Without the explicit Query(), a list[str] parameter would be interpreted as a body.

Query values arrive as strings, so this is lax Pydantic validation: ?limit=20 becomes the integer twenty, ?status=urgent returns a 422 naming the allowed values.

A query model

When a filter grows past four or five fields, group it. FastAPI accepts a Pydantic model for the query string, and the same model can be reused by another route.

from pydantic import BaseModel, Field

class TicketFilter(BaseModel):
    model_config = {"extra": "forbid"}
    status: Status | None = None
    priority: Literal["low", "normal", "high"] | None = None
    customer_id: int | None = None
    limit: int = Field(default=20, ge=1, le=100)
    offset: int = Field(default=0, ge=0)

@app.get("/tickets")
async def list_tickets(f: Annotated[TicketFilter, Query()]) -> list[Ticket]:
    return store.search(**f.model_dump(exclude_none=True))

Annotated[TicketFilter, Query()] is what tells FastAPI this model comes from the query string rather than the body. extra="forbid" turns an unknown query key into a 422 instead of ignoring it, which catches the ?stauts=open typo that would otherwise return every ticket.

Body

from fastapi import Body

class TicketIn(BaseModel):
    customer_id: int
    subject: str
    body: str

class Attachment(BaseModel):
    filename: str
    url: str

@app.post("/tickets", status_code=201)
async def create_ticket(ticket: TicketIn) -> Ticket:
    ...

@app.post("/tickets/{ticket_id}/attachments", status_code=201)
async def attach(
    ticket_id: Annotated[int, Path(gt=0)],
    attachment: Attachment,
    note: Annotated[str | None, Body(max_length=200)] = None,
) -> Ticket:
    ...

A single model parameter is the whole body. When a route has two body parameters, as attach does with attachment and note, FastAPI expects a JSON object with one key per parameter: {"attachment": {...}, "note": "..."}. To get that wrapping even with a single model, use Body(embed=True). The attach route also shows all three sources in one signature; nothing about that is special.

Parameter Declared as Read from
ticket_id Name appears in the path Path
status: Status | None = None Scalar with default Query
tag: Annotated[list[str], Query()] List with explicit Query() Query, repeated key
f: Annotated[TicketFilter, Query()] Model with explicit Query() Query, one key per field
ticket: TicketIn Pydantic model JSON body
note: Annotated[str, Body()] Scalar with explicit Body() JSON body, keyed by name

Engineering note. Put identity in the path, filters in the query, and documents in the body, and do not bend the rule for convenience. A POST /tickets/search with a body of filters looks harmless, but it cannot be bookmarked, cached, or linked from a log line, and someone will build a dashboard on top of it.

How FastAPI decides, in order

  1. If the name is in the path template, it is a path parameter. It is always required.
  2. If the annotation carries Path, Query, Body, Header, or Cookie, that wins.
  3. If the type is a Pydantic model, it is a body parameter.
  4. Otherwise (int, str, float, bool, Literal, datetime, UUID, and their optional forms) it is a query parameter.

Bools are parsed the lax Pydantic way, so ?urgent=true, ?urgent=1, and ?urgent=yes all mean True.

Where it goes wrong

  • Route order. @app.get("/tickets/{ticket_id}") declared before @app.get("/tickets/mine") captures the literal string mine as ticket_id and returns a 422. Declare fixed paths before parameterised ones.
  • A list without Query(). tags: list[str] = [] in a GET is treated as a body, and the route quietly expects JSON on a GET. Always wrap query lists in Annotated[..., Query()].
  • A body on GET. Many proxies and clients drop it. If a read needs a complex filter, use a query model.
  • The trailing slash. /tickets and /tickets/ are different routes; FastAPI redirects between them with a 307, which browsers follow but some clients do not. Pick one convention and register it.
  • Too-clever coercion. ?customer_id=7.0 is accepted for an int in lax mode. If exactness matters, validate with strict mode in the model.

Try it

Replace the in-memory list in main.py with a search function that applies TicketFilter, and expose it as GET /tickets. Then add PATCH /tickets/{ticket_id} that takes a TicketUpdate body with every field optional, uses model_dump(exclude_unset=True) from the serialisation lesson, and only changes what the client sent. Test through /docs: a filter typo should return 422, and a PATCH with {} should return the ticket unchanged.

With input covered, the next lesson turns to the other direction: response models, status codes, and what a route should say when it succeeds.

Free preview

Continue with the complete track

Keep your progress and unlock the surrounding lessons, exercises, and complete learning path.

Unlock the complete track
124 online