Module 1 · How FastAPI works

Path, query & header parameters

Intermediate 22 min read Type-driven parsing, Annotated validation, parameter models

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.

PATCH /orders/42?notify=true X-Request-ID: 7f3a9c21 Cookie: session=9f2c {"note": "oat milk"} pathorder_id: intnamed in the route querynotify: bool = Falsesimple types, not in path headerx_request_id: Header()_ becomes - cookiesession: Cookie()explicit marker bodypatch: OrderPatcha Pydantic model

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"])
200 {'customer_id': 7, 'status': None, 'paid': False, 'limit': 20} 200 {'customer_id': 7, 'status': 'shipped', 'paid': True, 'limit': 5} 422 ['query', 'limit'] 422 ['path', 'customer_id']
DeclarationSourceRequired?
name appears as {name} in the pathpathalways
limit: int (no default), simple typequeryyes: missing gives 422
limit: int = 20 / q: str | None = Nonequeryno
a Pydantic model (no marker)request body (lesson 04)yes, unless it has a default
Annotated[str, Header()], Cookie()header, cookiedepends 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())
buggy 422 {'detail': [{'type': 'int_parsing', 'loc': ['path', 'user_id'], 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': 'me'}]} fixed 200 {'user': 'me'}

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']}")
{'sku': 'TEA-01', 'q': None, 'tag': ['green', 'organic'], 'sort': 'total', 'limit': 20, 'page_token': 'abc'} /products/tea-1 → ['path', 'sku']: String should match pattern '^[A-Z]{3}-\d{2}$' /products/TEA-01?q=x → ['query', 'q']: String should have at least 2 characters /products/TEA-01?limit=500 → ['query', 'limit']: Input should be less than or equal to 100 /products/TEA-01?sort=price → ['query', 'sort']: Input should be 'created' or 'total'
A list default of [] 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"])
{'status': 'paid', 'created_after': '2026-09-01', 'limit': 20} 422 extra_forbidden ['query', 'stauts']

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())
{'agent': 'shop-app/2.1', 'request_id': 'req-42', 'languages': ['en-IN'], 'session': 'abc123'}

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

1 · GET /users/me returns 422 "Input should be a valid integer". Most likely cause?
Routes are tried in order. Declare fixed paths before parameterised ones.
2 · How do you accept ?tag=a&tag=b as a list?
Without Query(), a list-typed parameter would be treated as a request body. The marker says "repeated query parameter".
3 · Why put le=100 on a limit query parameter?
Unbounded page sizes are a classic API denial-of-service vector. It appears in the OWASP API Top 10 as unrestricted resource consumption.