Path, query & header parameters
Every value in a URL or header arrives as text. FastAPI decides where each function parameter comes
from and what type it should be from the signature: a name that appears in the path template is a path
parameter, a simple type that does not is a query parameter, and Header() or
Cookie() says so explicitly. Then Pydantic converts and validates. This lesson covers the rules,
the constraints you can declare, parameter models, and the routing trap that catches every team once.
An address, the notes on the envelope, and the stamp
The path is the address (/orders/42): it identifies which resource. Query parameters
are notes on the envelope (?status=paid&limit=20): how to filter or present it. Headers are
the postal stamps and routing codes (Authorization, X-Request-ID): information
about the delivery itself. FastAPI reads all three and checks every one against the form you declared.
1. Where each parameter comes from
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/customers/{customer_id}/orders")
def list_orders(customer_id: int, # in the path template → path parameter
status: str | None = None, # not in the path, has a default → optional query
paid: bool = False, # bool accepts true/false/1/0/yes/no/on/off
limit: int = 20):
return {"customer_id": customer_id, "status": status, "paid": paid, "limit": limit}
client = TestClient(app)
for url in ["/customers/7/orders",
"/customers/7/orders?status=shipped&paid=yes&limit=5",
"/customers/7/orders?limit=many",
"/customers/seven/orders"]:
r = client.get(url)
print(r.status_code, r.json() if r.status_code == 200 else r.json()["detail"][0]["loc"])
| Declaration | Source | Required? |
|---|---|---|
name appears as {name} in the path | path | always |
limit: int (no default), simple type | query | yes: missing gives 422 |
limit: int = 20 / q: str | None = None | query | no |
| a Pydantic model (no marker) | request body (lesson 04) | yes, unless it has a default |
Annotated[str, Header()], Cookie() | header, cookie | depends on the default |
2. Route order: the bug everyone writes once
Starlette tries routes in the order they were declared and uses the first whose pattern matches.
/users/{user_id} matches /users/me too, with user_id="me", which then
fails int validation:
from fastapi import FastAPI
from fastapi.testclient import TestClient
buggy = FastAPI()
@buggy.get("/users/{user_id}")
def get_user(user_id: int):
return {"user_id": user_id}
@buggy.get("/users/me") # never reached: the route above matched first
def get_me():
return {"user": "me"}
fixed = FastAPI()
@fixed.get("/users/me") # fixed paths BEFORE parameterised ones
def get_me_fixed():
return {"user": "me"}
@fixed.get("/users/{user_id}")
def get_user_fixed(user_id: int):
return {"user_id": user_id}
for name, app in [("buggy", buggy), ("fixed", fixed)]:
r = TestClient(app).get("/users/me")
print(name, r.status_code, r.json())
3. Validation with Annotated
Annotated[type, Query(...)] attaches constraints and documentation without changing the type.
This is the style FastAPI recommends. It keeps the real default in the normal place, and the same alias can
be reused across endpoints:
from enum import Enum
from typing import Annotated
from fastapi import FastAPI, Path, Query
from fastapi.testclient import TestClient
class SortField(str, Enum):
created = "created"
total = "total"
Limit = Annotated[int, Query(ge=1, le=100, description="Page size")] # a reusable parameter type
app = FastAPI()
@app.get("/products/{sku}")
def search(sku: Annotated[str, Path(pattern=r"^[A-Z]{3}-\d{2}$")],
q: Annotated[str | None, Query(min_length=2, max_length=50)] = None,
tag: Annotated[list[str], Query()] = [], # ?tag=a&tag=b → ["a", "b"]
sort: SortField = SortField.created,
limit: Limit = 20,
page_token: Annotated[str | None, Query(alias="page-token")] = None):
return {"sku": sku, "q": q, "tag": tag, "sort": sort, "limit": limit, "page_token": page_token}
client = TestClient(app)
print(client.get("/products/TEA-01?tag=green&tag=organic&sort=total&page-token=abc").json())
for url in ["/products/tea-1", "/products/TEA-01?q=x", "/products/TEA-01?limit=500", "/products/TEA-01?sort=price"]:
err = client.get(url).json()["detail"][0]
print(f"{url:30} → {err['loc']}: {err['msg']}")
[] is safe here
Core Python warned about mutable defaults. For FastAPI parameters and Pydantic fields it is fine: the framework builds a fresh value for every request (Pydantic copies defaults), so no two requests share the list.
4. Query parameter models (FastAPI 0.115+)
When several endpoints share the same filters, declare them once as a Pydantic model and mark it with
Query(). With extra="forbid", unknown query parameters become a 422 instead of being
silently ignored, which catches client typos such as ?stauts=paid:
from datetime import date
from typing import Annotated, Literal
from fastapi import FastAPI, Query
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field
class OrderFilters(BaseModel):
model_config = ConfigDict(extra="forbid")
status: Literal["pending", "paid", "shipped"] | None = None
created_after: date | None = None
limit: int = Field(20, ge=1, le=100)
app = FastAPI()
@app.get("/orders")
def list_orders(filters: Annotated[OrderFilters, Query()]):
return filters.model_dump(exclude_none=True)
client = TestClient(app)
print(client.get("/orders?status=paid&created_after=2026-09-01").json())
r = client.get("/orders?stauts=paid")
print(r.status_code, r.json()["detail"][0]["type"], r.json()["detail"][0]["loc"])
5. Headers and cookies
from typing import Annotated
from fastapi import Cookie, FastAPI, Header
from fastapi.testclient import TestClient
app = FastAPI()
@app.get("/whoami")
def whoami(user_agent: Annotated[str | None, Header()] = None, # reads User-Agent
x_request_id: Annotated[str | None, Header()] = None, # reads X-Request-ID
accept_language: Annotated[list[str], Header()] = [], # repeated headers → list
session: Annotated[str | None, Cookie()] = None):
return {"agent": user_agent, "request_id": x_request_id, "languages": accept_language, "session": session}
client = TestClient(app, cookies={"session": "abc123"})
print(client.get("/whoami", headers={"User-Agent": "shop-app/2.1", "X-Request-ID": "req-42",
"Accept-Language": "en-IN"}).json())
Header names are case-insensitive in HTTP, and Python names cannot contain hyphens, so FastAPI converts
x_request_id to x-request-id. Authentication headers are usually read through
security dependencies instead (lesson 17), which also document the scheme in OpenAPI.
6. Rules that prevent bad APIs
Validate at the edge
Put ranges, lengths and patterns in the signature. Every constraint you declare is enforced, documented, and one less check inside your function.
Cap every list and page size
le=100 on limit is a security control: without it, ?limit=10000000 is a denial-of-service request.
Identifiers in the path, options in the query
/orders/42?expand=items, not /orders?id=42&expand=items.
Forbid unknown filters
With parameter models and extra="forbid", a typo becomes a clear 422 instead of a silently unfiltered result.
Recap
- Path = names in the template; query = other simple parameters; Header/Cookie = explicit markers.
- Routes match in declaration order: fixed paths before parameterised ones.
- Annotated + Query/Path for constraints (
ge,le,pattern, lengths), aliases, lists and enums. - Parameter models reuse filters;
extra="forbid"turns typos into 422s.
Checkpoint
GET /users/me returns 422 "Input should be a valid integer". Most likely cause?
?tag=a&tag=b as a list?
le=100 on a limit query parameter?