FastAPI Path Parameters and Query Parameters
Query() and Path().- Hierarchical data that identifies a specific resource belongs in path parameters. Non-hierarchical data that filters, sorts, or paginates belongs in query parameters. This distinction is not stylistic — it affects caching, client expectations, and how CDNs handle your URLs.
- Type hints are the single source of truth. FastAPI reads them to perform validation, type coercion, and OpenAPI documentation generation. Write accurate type hints and the rest follows automatically.
- Default values determine requirement. An argument without a default is a required parameter — FastAPI will reject requests that omit it. An argument with a default is optional. This is the entire rule.
- Path parameters live inside the route URL with curly braces (e.g., /items/{id}) and identify a specific resource
- Query parameters are any function arguments not found in the path string — used for filtering, sorting, and pagination
- FastAPI uses Python type hints to automatically validate, convert, and document all parameters via OpenAPI
- A type mismatch triggers a structured 422 Unprocessable Entity error before your logic executes
- Annotated is the modern pattern for combining type hints with Query() or Path() validation without breaking IDE autocompletion
- Default values dictate requirement: no default = required parameter; with default = optional
Unexpected 422 responses
curl -s 'http://localhost:8000/forge/search?query=api&page=abc' | python -m json.toolcurl -s 'http://localhost:8000/openapi.json' | python -c "import sys,json; print(json.dumps(json.load(sys.stdin)['paths']['/forge/search'], indent=2))"Path parameter not matching nested routes with slashes
grep -n 'path' app/routes/*.pycurl -v 'http://localhost:8000/forge/assets/images/logo.png'Query parameter ignored or defaulting unexpectedly on every request
curl -v 'http://localhost:8000/forge/search?query=fastapi&page=2' 2>&1 | grep -i 'page'python -c "from app.main import app; print([r.path for r in app.routes])"Production Incident
Production Debug GuideSymptom → Action mapping for common parameter failures
Parameter handling is the boundary between untrusted client input and your core business logic. In traditional frameworks, validation is an afterthought — you manually cast strings, check for None, and pray edge cases don't slip through. I've seen that pattern collapse under production load more times than I'd like to admit.
FastAPI inverts this model entirely. By leveraging Python's type hinting system, parameters become a typed contract. FastAPI validates against a schema, converts to the correct Python object, and generates interactive OpenAPI documentation — all from your function signature alone. There is no separate validation layer to synchronize, no schema file to keep in sync with your code.
The distinction between path and query parameters is not cosmetic. Path parameters identify a specific resource — they are hierarchical, always required, and semantically load-bearing. Query parameters modify the representation of that resource — they are non-hierarchical, often optional, and composable. Mixing them up produces confusing APIs that frustrate clients, break caching strategies, and signal a shaky understanding of REST resource modeling to anyone reading your routes.
Path Parameters: Resource Identification
Path parameters exist for one reason: to uniquely identify a resource within your URL hierarchy. They are not filters, they are not options, and they are not toggles. If you find yourself putting something optional into a path segment, that is a design smell worth stopping to examine.
FastAPI supports a special syntax called 'Path Converters' that extend the default matching behavior. The most important is the :path converter — it tells FastAPI to match everything remaining in the URL, including forward slashes. Without it, a path parameter stops at the first / it encounters. This makes :path essential for building file explorers, storage proxies, or any service where the resource identifier is itself a hierarchical path.
Path parameters are inherently required at the routing level. If a client omits a path segment, the route simply does not match — the response is a 404, not a 422. The 422 only appears after route matching succeeds and FastAPI attempts to validate the captured value against your type hint and any Path() constraints. This two-stage process means your validation logic never has to handle a missing path parameter; the router handles that before Pydantic is involved.
from fastapi import FastAPI, Path from typing import Annotated app = FastAPI() @app.get('/forge/users/{user_id}') def get_user_by_id( # Annotated keeps the base type visible to IDEs and type checkers. # Path() attaches validation metadata without polluting the default value. user_id: Annotated[ int, Path( title='User ID', description='The internal registry ID of the user. Must be a positive integer.', ge=1, ), ], ): """ Fetch a single user from the internal forge registry by their numeric ID. IDs start at 1 — zero and negatives are rejected at the boundary. """ return {'user_id': user_id, 'context': 'internal_forge_registry'} # The :path converter is required for any parameter that may contain slashes. # Without it, /forge/assets/images/branding/logo.png would bind only # 'images' to file_path and the route would 404 on the remainder. @app.get('/forge/assets/{file_path:path}') def get_asset_stream(file_path: str): """ Stream a static asset by its full storage path, including subdirectories. file_path captures everything after /forge/assets/ including slashes. """ return {'requested_path': file_path} # GET /forge/users/42 # -> {'user_id': 42, 'context': 'internal_forge_registry'} # GET /forge/users/0 # -> 422 Unprocessable Entity (ge=1 constraint violated) # GET /forge/assets/images/branding/logo.png # -> {'requested_path': 'images/branding/logo.png'}
- Path = identity. Query = representation. Blurring this line produces APIs that are hard to cache, hard to document, and hard to reason about six months later.
- A missing path segment produces 404 (route not matched), not 422 (validation failed). These are different failure modes and must be handled differently in clients.
- Use ge=1 or gt=0 on numeric IDs to reject zero and negatives at the boundary — before they reach your database layer and produce confusing errors downstream.
- The :path converter is strictly opt-in. Standard path parameters stop at the first slash by design; the converter is your explicit declaration that slashes are part of the value.
- Path parameters arrive as strings at the HTTP layer. FastAPI uses your type hint to coerce them — so int user_id does not mean the URL contains a binary integer; it means FastAPI will attempt to parse the string '42' into the Python integer 42.
Path() for clean, IDE-friendly validation metadata. Skip constraint validation and you are just deferring the data quality problem downstream.Path() — these appear directly in Swagger UI and ReDoc without any extra configurationQuery Parameters: Filtering and Pagination
Query parameters handle everything that modifies how a resource is represented, rather than which resource you are talking about. Filtering a list by status, sorting results by a field, paginating through a large dataset — these all belong in query parameters. They appear after the ? in the URL, are separated by &, and carry no positional significance. /search?q=fastapi&page=2 is semantically identical to /search?page=2&q=fastapi — order does not matter.
In FastAPI, any function argument that does not appear in the route path string is automatically treated as a query parameter. Providing a default value makes it optional. Omitting the default makes it required — FastAPI will reject requests without it and return a 422 with a clear error pointing at the missing field.
FastAPI also supports multi-value query parameters natively. Define a parameter as list[str] and the client sends the same key multiple times: ?tag=python&tag=web&tag=api. FastAPI collects all values and delivers them as a Python list. There is no custom parsing logic required on your end. If the client sends no values for a list parameter, the result is an empty list — unless you set a default with Query().
One thing worth being deliberate about: the boolean type in FastAPI is more permissive than you might expect. It accepts 'true', '1', 'on', 'yes', 'True' as True and their inverses as False. This is intentional — it makes the API compatible with curl, shell scripts, and frontend libraries that serialize booleans differently. Know this behavior exists so it does not surprise you in testing.
from fastapi import FastAPI from typing import Optional app = FastAPI() @app.get('/forge/search') def list_artifacts( query: str, # Required — no default means the client must provide this page: int = 1, # Optional — defaults to page 1 if not provided tag: Optional[str] = None, # Optional — defaults to None, filtering is skipped active: bool = True, # FastAPI accepts 'true', '1', 'yes' as True ): """ Search artifacts in the forge registry. - query: full-text search term (required) - page: pagination offset, 1-indexed (optional, default 1) - tag: filter results to a specific tag (optional) - active: include only active artifacts when True (optional, default True) """ return { 'search_term': query, 'pagination': {'page': page}, 'filter': {'tag': tag, 'active': active}, } # GET /forge/search?query=api # -> {'search_term': 'api', 'pagination': {'page': 1}, 'filter': {'tag': None, 'active': True}} # GET /forge/search?query=api&page=3&tag=infra&active=false # -> {'search_term': 'api', 'pagination': {'page': 3}, 'filter': {'tag': 'infra', 'active': False}} # GET /forge/search # -> 422 Unprocessable Entity (query is required, no default provided)
Query() any time you need a business rule enforced at the boundary, not just a type check.Complex Validation with Query()
Type hints handle the common case — an integer is an integer, a string is a string. But real production APIs have business rules that go beyond type correctness. A service identifier must start with a specific prefix. A pagination limit must be between 1 and 100. A search term must be at least three characters long. These are not type constraints; they are domain constraints, and they belong at the API boundary, not buried inside your business logic.
The Query() class is how you express those constraints in FastAPI. It accepts ge (greater than or equal), gt (greater than), le (less than or equal), lt (less than), min_length, max_length, and pattern for regular expression matching. Every constraint is enforced by Pydantic before your function body executes — a request that violates any constraint is rejected with a structured 422 response containing the field name, the failed constraint, and a human-readable message.
The Annotated pattern is the right way to combine Query() with your type hint. Annotated[str, Query(min_length=3, pattern='^FORGE-.*$')] keeps the base type (str) visible to IDEs and type checkers, while attaching the validation metadata separately. The older pattern — param: str = Query(min_length=3) — works but conflates the default value position with the Query object, which confuses IDEs and breaks autocompletion in some editors.
One constraint worth measuring before you ship: regex patterns via Query(pattern=...) run on every request. A poorly anchored or backtracking-prone regex on a high-traffic endpoint will show up in your profiler. Always anchor your patterns (use ^ and $), test them against pathological inputs, and benchmark them under load before deploying on hot paths. For simple guards like minimum length or maximum value, prefer the numeric and length constraints over regex — they are faster and the error messages are clearer.
from fastapi import FastAPI, Query from typing import Annotated app = FastAPI() @app.get('/forge/audit-logs') def get_audit_logs( # service_id must start with 'FORGE-' and be at least 3 characters. # Annotated keeps str visible to IDEs; Query() attaches the constraints. service_id: Annotated[ str, Query( title='Service Identifier', description='Internal service ID. Must use the FORGE- prefix.', min_length=3, pattern='^FORGE-.*$', ), ], # limit is optional (defaults to 20) but must be between 1 and 100. # Query() on a default value enforces the constraint on every request, # including those that omit the parameter and trigger the default. limit: Annotated[ int, Query( title='Result Limit', description='Maximum number of log entries to return. Range: 1–100.', ge=1, le=100, ), ] = 20, ): """ Retrieve audit log entries for a specific internal service. Both parameters are validated before this function body runs. Invalid input never reaches the log query logic. """ return {'service': service_id, 'limit': limit} # GET /forge/audit-logs?service_id=FORGE-AUTH&limit=50 # -> {'service': 'FORGE-AUTH', 'limit': 50} # GET /forge/audit-logs?service_id=EXTERNAL-SVC&limit=50 # -> 422 Unprocessable Entity # -> detail: [{'loc': ['query', 'service_id'], 'msg': 'String should match pattern ...', 'type': 'string_pattern_mismatch'}] # GET /forge/audit-logs?service_id=FORGE-AUTH&limit=500 # -> 422 Unprocessable Entity # -> detail: [{'loc': ['query', 'limit'], 'msg': 'Input should be less than or equal to 100', 'type': 'less_than_equal'}]
- Each constraint (ge, le, min_length, pattern) compiles into a Pydantic field validator at app startup — not at request time. The overhead per request is minimal.
- Validation failures produce structured 422 responses with field-level detail. The loc array tells clients exactly which parameter failed. The type field gives them a machine-readable error code. Build your client error handling around these fields.
- Constraints run at the application boundary — the earliest possible moment. This keeps invalid data out of your service layer, your database queries, and your audit logs.
- The pattern parameter uses Python re syntax. Always anchor patterns with ^ and $. An unanchored pattern like FORGE-.* will match 'PREFIX-FORGE-AUTH' and that is probably not what you intend.
- Combine Annotated with
Query()for the cleanest possible signature. The type is the type; the validation metadata is the metadata. They live in different positions for a reason.
Query() transforms type hints into executable business rules — string patterns, numeric ranges, and length bounds enforced before your function body runs.| Aspect | Path Parameter | Query Parameter |
|---|---|---|
| Purpose | Identify a specific resource — answers 'which one?' | Filter, sort, or paginate resources — answers 'in what form?' |
| URL Position | Embedded in the route path (e.g., /users/{id}) | After the ? in the URL (e.g., ?page=2&status=active) |
| Requirement | Always required — a missing segment produces 404 at routing, before validation runs | Optional when a default value is provided; required without one |
| Validation Error | 422 if captured value fails type conversion or Path() constraints | 422 if provided value fails type conversion or Query() constraints |
| Order Sensitivity | Positional — the first {placeholder} matches the first path segment | Order-independent — matched by key name, not position |
| Multiple Values | Not supported — path segments are singular by definition | Supported via list[type] type hint; client repeats the key in the URL |
| OpenAPI Placement | Documented under 'Parameters' with 'in': 'path' | Documented under 'Parameters' with 'in': 'query' |
| Cache Impact | Part of the URL path — a standard, stable cache key component | Part of the query string — cache behavior varies by proxy; normalize key order to maximize hit rates |
🎯 Key Takeaways
- Hierarchical data that identifies a specific resource belongs in path parameters. Non-hierarchical data that filters, sorts, or paginates belongs in query parameters. This distinction is not stylistic — it affects caching, client expectations, and how CDNs handle your URLs.
- Type hints are the single source of truth. FastAPI reads them to perform validation, type coercion, and OpenAPI documentation generation. Write accurate type hints and the rest follows automatically.
- Default values determine requirement. An argument without a default is a required parameter — FastAPI will reject requests that omit it. An argument with a default is optional. This is the entire rule.
- FastAPI supports multi-value query parameters natively. Define the parameter as list[str] and the client repeats the key: ?tag=python&tag=web. No custom parsing required.
- Annotated is the correct pattern for combining type hints with
Query()orPath()metadata. It keeps the base type visible to IDEs, type checkers, and every tool in the Python ecosystem. - The :path converter is required for capturing path parameter values that contain forward slashes. Without it, matching stops at the first slash. This is opt-in by design — you must declare it explicitly.
- 422 errors contain structured detail arrays — parse them in your clients instead of treating them as opaque failures. The loc, msg, and type fields tell you exactly which parameter failed and why.
- Regex constraints via Query(pattern=...) have CPU cost on hot paths. Anchor your patterns, test them against adversarial inputs, and measure before deploying on high-traffic endpoints. Prefer numeric and length constraints where they are sufficient.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QHow does FastAPI's internal resolution logic determine if a function argument is a Body, Path, or Query parameter?SeniorReveal
- QWhy is the Annotated pattern preferred over assigning
Query()orPath()directly as a default value in modern FastAPI versions?Mid-levelReveal - QExplain the Path Converter concept. How would you capture a full file path including slashes as a single path parameter?Mid-levelReveal
- QScenario: You have a query parameter tags that should accept a list of strings. How do you define this so it appears correctly in generated OpenAPI documentation?Mid-levelReveal
- QIn a high-performance system, how does FastAPI's use of Pydantic for parameter validation impact latency compared to raw dictionary access?SeniorReveal
Frequently Asked Questions
What is the difference between a path parameter and a query parameter?
Path parameters are structural parts of the URL that identify a specific resource. /users/123 uses 123 as a path parameter — removing it changes which resource you are addressing, or produces a 404 if the route no longer matches. Query parameters appear after the ? and modify the representation of a resource. /users?role=admin&page=2 returns a filtered, paginated view of the users resource without changing the resource being addressed. The rule of thumb: if the API makes no sense without the value, it is a path parameter. If the API returns a sensible (if broad) result without the value, it is a query parameter.
How does FastAPI handle boolean query parameters?
FastAPI is deliberately permissive with booleans in query parameters to maximize compatibility with clients that serialize booleans differently. The values 'true', '1', 'on', 'yes', and 'True' are all interpreted as Python True. The values 'false', '0', 'off', 'no', and 'False' are all interpreted as Python False. This means curl -d 'active=yes', a JavaScript client sending 'active=true', and a Python script sending 'active=1' all produce the same result. If your API requires stricter boolean semantics — only accepting 'true' or 'false', for example — you will need a custom validator, because FastAPI's built-in boolean coercion is intentionally broad.
Can a query parameter be a list?
Yes, and it works without any extra configuration. Define the parameter as list[str] or List[int] and FastAPI will collect all instances of that key from the query string. A URL like ?id=1&id=2&id=3 produces the Python list [1, 2, 3] for a parameter typed as list[int]. A single ?id=1 produces [1] — still a list, just with one element. If no values are provided and you have a default of Query(default=[]), the function receives an empty list. If you need to accept comma-separated strings like ?ids=1,2,3 instead of repeated keys, that requires a custom validator — FastAPI does not split on commas by default.
What is the 422 Unprocessable Entity error in FastAPI?
A 422 error means the request was structurally valid HTTP — proper headers, parseable URL — but the content was semantically invalid against the API's schema. The most common causes with FastAPI are: sending a string where an integer is expected, omitting a required parameter, violating a constraint like a minimum value or a regex pattern, or sending a value that cannot be coerced to the expected type.
FastAPI automatically generates a detailed JSON response body for every 422. The body contains a detail array where each entry has loc (a list showing the parameter location and name), msg (a human-readable description of what failed), and type (a machine-readable Pydantic error code). Always parse these fields in your client code — they remove the guesswork from debugging parameter failures and make it possible to surface actionable errors to end users.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.