Path and Query Parameters
Path and query parameters are the part of a FastAPI route where URL text becomes typed Python values. A path parameter identifies the resource selected by the route, such as /users/42. A query parameter modifies the representation or search operation, such as ?limit=20&offset=40. By the end of this lesson, you should be able to choose which values belong in the path, which belong in the query string, how FastAPI converts them, how validation errors are produced, and how those choices appear in generated OpenAPI documentation.
This topic sits in the Routing and Schemas section because FastAPI routing is not only URL matching. Route declarations, function signatures, type annotations, and parameter helper functions become an executable schema. That schema decides whether a request reaches your endpoint function and what Python objects the function receives.
How FastAPI Finds and Parses Parameters
FastAPI builds on Starlette routing. When the application starts, each path operation decorator registers an HTTP method and a path template. A template segment wrapped in braces, such as {user_id}, is a path parameter. For an incoming request, Starlette compares the request path against registered routes in order and extracts the text matched by each parameter segment. FastAPI then inspects the endpoint function signature and decides where every argument should come from.
The source rule is practical. If a function argument name also appears in the path template, FastAPI treats it as a path parameter. Simple scalar arguments that do not appear in the path, such as limit: int = 10, are treated as query parameters. Request bodies are used for Pydantic models and body declarations, which are covered elsewhere. The important point here is that user_id: int does not merely annotate documentation. FastAPI asks Pydantic to parse the extracted string into an integer before your function runs.
If parsing or validation fails, your endpoint function is skipped and FastAPI returns a 422 response with a structured detail list. Each error points to a location such as ["path", "user_id"] or ["query", "limit"]. That location is often the fastest way to diagnose whether a client sent a malformed identity value, a bad filter, or a parameter under the wrong name.
API Anatomy
A path parameter is declared in the URL template and repeated as a function argument. It is required because the route cannot match without that segment. Query parameters are declared as function arguments that have defaults or explicit query metadata. They are optional when the default is None or another value, and required when no default is supplied.
FastAPI provides Path and Query helper functions for constraints and documentation. Common constraints include ge and le for numeric bounds, min_length and max_length for strings, and pattern for regular expression matching. With modern Python, typing.Annotated keeps the actual type separate from FastAPI metadata: Annotated[int, Query(ge=1, le=100)] means the value must parse as an integer and must be between 1 and 100.
Use the path for stable resource identity and hierarchy: account id, project id, invoice id, or region when it is part of the addressable collection. Use query parameters for sorting, filtering, pagination, field selection, and optional presentation choices. A useful test is whether changing the value changes the resource being addressed or changes how a collection is viewed. /users/42 names one user. /users?status=active asks for a filtered view of the users collection.
Example 1: A Typed Resource Identifier
from fastapi import FastAPI
app = FastAPI(title="Parameter demo")
@app.get("/users/{user_id}")
async def read_user(user_id: int):
return {"user_id": user_id, "type": type(user_id).__name__}
In this route, user_id appears in the path template and in the function signature, so FastAPI reads it from the path. A request to GET /users/42 returns {"user_id": 42, "type": "int"}. The string "42" from the URL has become the integer 42 before the function body executes. A request to GET /users/alice does not call read_user; it returns 422 because alice cannot be parsed as an integer.
This behavior matters when endpoint code calls a database or service layer. The service can receive int, UUID, or Enum values instead of reparsing raw URL strings. You still need authorization and existence checks, but basic shape validation has already happened at the HTTP boundary.
Example 2: Filtering and Pagination
from typing import Annotated
from fastapi import FastAPI, Query
app = FastAPI(title="Pagination demo")
PRODUCTS = [
{"id": 1, "name": "keyboard", "category": "hardware"},
{"id": 2, "name": "monitor", "category": "hardware"},
{"id": 3, "name": "notebook", "category": "stationery"},
]
@app.get("/products")
async def list_products(
category: str | None = None,
limit: Annotated[int, Query(ge=1, le=50)] = 10,
offset: Annotated[int, Query(ge=0)] = 0,
):
rows = PRODUCTS
if category is not None:
rows = [item for item in rows if item["category"] == category]
return {"count": len(rows), "items": rows[offset:offset + limit]}
This route has no path parameters. category, limit, and offset are query parameters because they are simple arguments that are not in the path template. GET /products returns all three products because category defaults to None, limit defaults to 10, and offset defaults to 0. GET /products?category=hardware&limit=1 returns {"count": 2, "items": [{"id": 1, "name": "keyboard", "category": "hardware"}]}.
The constraints protect both callers and the server. limit=0 is rejected because the lower bound is 1. limit=500 is rejected because the upper bound is 50. Without that bound, a harmless looking request could ask the service to load or serialize too much data. Pagination parameters are query parameters because they alter the window of a collection, not the identity of the collection itself.
Example 3: Enums, UUIDs, and Combined Parameters
from enum import Enum
from typing import Annotated, Literal
from uuid import UUID
from fastapi import FastAPI, Query
app = FastAPI(title="Order search demo")
class Region(str, Enum):
us = "us"
eu = "eu"
@app.get("/regions/{region}/customers/{customer_id}/orders")
async def search_orders(
region: Region,
customer_id: UUID,
status: Annotated[Literal["open", "paid", "cancelled"] | None, Query()] = None,
min_total: Annotated[float | None, Query(ge=0)] = None,
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=100)] = 25,
):
return {
"region": region,
"customer_id": str(customer_id),
"filters": {"status": status, "min_total": min_total},
"pagination": {"page": page, "page_size": page_size},
}
This example combines a hierarchical path with optional search controls. region is an enum, so only us and eu are valid path values. customer_id must parse as a UUID. status is an optional literal query parameter, which means clients may omit it, but if they include it the value must be open, paid, or cancelled. min_total, page, and page_size use numeric bounds.
A request to /regions/us/customers/4fdabe63-7c55-4d77-8d7a-a785fdc19f33/orders?status=open&min_total=25&page=2 returns the parsed region, UUID string, filters, and pagination object. A request with /regions/apac/... returns 422 because apac is not a member of the enum. A request with ?status=late also returns 422, but the error location is in the query rather than the path.
Design Choices and Trade-offs
Choosing path versus query shape affects caching, documentation, client code, and future compatibility. Path parameters are prominent and usually required by generated clients. They are appropriate for identities that must be present and that form a stable route. However, putting too much variability in the path creates many route shapes and makes optional combinations awkward.
Query parameters are better for optional controls and collection operations. They are naturally extensible because adding a new optional query parameter does not change the route path. The trade-off is that query strings can become vague if names are inconsistent or constraints are missing. Prefer precise names like created_after, sort, and page_size over generic names like filter unless the value has its own documented syntax.
Route order is another real design concern. A dynamic route such as /users/{user_id} can match text that you intended for a fixed route such as /users/me if it is registered first. Put fixed, specific routes before dynamic routes. Better yet, choose route patterns that make ambiguity rare.
Failure Modes and Troubleshooting
Symptom: the client receives 422 before endpoint logs appear. Cause: FastAPI rejected a path or query value during validation. Diagnostic steps: inspect the response detail array and check the loc field. Confirm the parameter name, value, and type annotation. Correction: send the parameter under the documented name, adjust the type if the API design is wrong, or add an explicit constraint message in your client documentation.
Symptom: /users/me is handled by /users/{user_id}. Cause: the dynamic route was registered before the fixed route. Diagnostic steps: inspect the order of decorators in the module and reproduce with the interactive docs or a small TestClient test. Correction: declare /users/me before /users/{user_id}, or redesign the dynamic route so fixed names cannot collide with ids.
Symptom: a filter seems ignored. Cause: the client may be sending a different query parameter name, such as pageSize instead of page_size, or the server default may be hiding the mistake. Diagnostic steps: compare the request URL with the OpenAPI schema and add a test for the exact URL. Correction: standardize names, use aliases deliberately when compatibility requires them, and avoid silently accepting multiple spellings unless you document them.
Security, Performance, and Reliability
Validation is not authorization. A valid customer_id only proves the value has the right shape; it does not prove the caller may access that customer. Always check permissions after parsing and before returning data. Bounds on query parameters are a reliability control because they limit expensive reads, large responses, and accidental denial of service. Enums and literals reduce unexpected branches by narrowing accepted values.
Be careful with logs. URLs often end up in access logs, browser history, and monitoring tools. Do not put secrets, bearer tokens, reset tokens, or sensitive free text in query parameters. Use headers or request bodies for sensitive values when the operation requires them, and apply the same retention policy to route logs as to other request metadata.
Hands-on Lab
Prerequisites: Python, FastAPI, and a test runner such as pytest. Work in a small project with main.py for the app and test_main.py for tests.
- Create
main.pywith the first two examples: the/users/{user_id}route and the/productsroute. - Start the app with an ASGI server, for example
uvicorn main:app --reload. - Open
/docsand inspect the generated parameters. Confirm thatuser_idappears as a required path parameter andlimitappears as a bounded query parameter. - Call
/users/42,/users/not-an-int,/products?category=hardware&limit=1, and/products?limit=500. - Add the tests below in
test_main.pyand runpytest.
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_path_parameter_is_converted_to_int():
response = client.get("/users/42")
assert response.status_code == 200
assert response.json() == {"user_id": 42, "type": "int"}
def test_invalid_path_parameter_returns_422():
response = client.get("/users/not-an-int")
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["path", "user_id"]
def test_query_parameter_bounds_are_enforced():
response = client.get("/products?limit=500")
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["query", "limit"]
Verification: the valid user request should return status 200 with an integer id, the invalid user id should return 422 with a path error location, and the oversized product limit should return 422 with a query error location. Cleanup: stop the development server. If you created a temporary virtual environment for the lab, deactivate it and remove the temporary project directory when finished.
Assessment Exercises
- Design a route for listing invoices for one account with optional status, date range, and page size. Which values belong in the path, and which belong in the query string? Explain the reason for each choice.
- Write a FastAPI parameter declaration for
page_sizethat defaults to25, rejects values below1, and rejects values above100. - A client reports that
/projects/archivereturns a validation error sayingarchiveis not a valid integer forproject_id. What route-order problem would you look for, and how would you test the fix? - Explain why a UUID path parameter still requires an authorization check before returning the matching record.
- Given
/search?q=red&q=blue, how would you change the endpoint signature if repeated query values are part of the intended API?
Summary
FastAPI path and query parameters turn URL components into typed, validated function arguments. Path parameters identify the resource or collection being addressed; query parameters refine listing, filtering, sorting, pagination, and presentation. The mechanism is driven by route templates, endpoint signatures, type annotations, and Path or Query metadata. Good designs use clear names, tight bounds, unambiguous route order, and tests that check both successful parsing and validation failure behavior.
