I‘ve watched teams add /products/by-category, /products/by-price, /products/by-rating, then wonder why docs feel fractured. A small decision — choosing where to put optional input — ends up shaping the whole API surface. Query parameters are the quiet workhorses here. They let you ask for exactly what you need without turning every filter into a new endpoint. Think of them like the sticky notes you add to a standard order form: same form, different instructions.
When I build FastAPI services, I treat query parameters as part of the public contract. The framework makes them type-aware, validated, and documented with almost no ceremony. That means you can keep one endpoint and still support search, filtering, sorting, pagination, and feature toggles. It also means clients get clear errors when they send bad input, instead of vague 500s.
Below, I‘ll walk through the practical side: how FastAPI maps query parameters to function arguments, how I design predictable APIs, and the patterns I rely on for safety, performance, and clarity. You‘ll get runnable examples and a few hard-earned gotchas. I‘m using the docs-expert lens here to keep the guide runnable and easy to scan, and I expanded the original draft with sections on pagination, sorting, filtering, edge cases, testing, and production considerations.
Query parameters vs path parameters: the URL contract
When I design an endpoint, I decide which parts of the request identify a resource and which parts shape the result. Path parameters belong to the identity. They live in the path segment and are always required. If your route is /users/{userid}, the caller must supply a userid, or the request does not match. That makes path parameters ideal for things that are truly part of the resource name: an order id, a team slug, or a report id.
Query parameters belong to the shape. They appear after ? in the URL, each key=value pair separated by &. FastAPI treats any function argument that is not part of the path as a query parameter. If you give it a default value, it becomes optional. If you do not, FastAPI treats it as required and will return a 422 validation error when it is missing. I use that rule as a design tool: required query parameters signal that the endpoint needs extra input even though it is not part of the path.
Here is a tiny greeting endpoint that shows this behavior:
from fastapi import FastAPI
app = FastAPI()
@app.get(‘/hello‘)
def hello(name: str | None = None):
if name:
return {‘message‘: f‘Hello, {name}.‘}
return {‘message‘: ‘Hello there.‘}
Call it with /hello?name=Amina and you will get a personalized message. Call it with /hello and you will get the default message. This is a great place to start when you are teaching your team what query parameters feel like in practice.
Now compare that with a path parameter. If I want a profile, the id belongs in the path, not the query string:
from fastapi import FastAPI
app = FastAPI()
@app.get(‘/users/{user_id}‘)
def getuser(userid: int, verbose: bool = False):
# verbose is a query parameter that shapes the response
return {‘userid‘: userid, ‘verbose‘: verbose}
The URL /users/42?verbose=true matches the route and returns user 42 with extra detail. The URL /users?user_id=42 would not match this route at all unless you also define a separate /users endpoint. That distinction matters: path parameters are part of routing, query parameters are not.
From a contract perspective, I use this rule of thumb:
- Path parameters identify a single resource or a tight collection.
- Query parameters shape, filter, or expand the response for that resource.
- If removing a parameter changes the identity of what you are asking for, it likely belongs in the path.
- If removing a parameter simply changes the subset or presentation, it belongs in the query.
That mindset keeps endpoints stable while still allowing flexibility.
How FastAPI maps query parameters to function arguments
FastAPI builds its request handling from your function signature. Any parameter that is not declared in the path becomes a query parameter by default. Type hints are not just for static analysis; FastAPI uses them at runtime to parse, validate, and document the parameters. If a value cannot be parsed to the given type, FastAPI automatically returns a 422 error with details.
The most direct way to influence a query parameter is to wrap it in Query(...). That lets you set constraints and documentation metadata without changing the base type.
from fastapi import FastAPI, Query
app = FastAPI()
@app.get(‘/search‘)
def search(
q: str = Query(..., min_length=2, description=‘Search term‘),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
return {‘q‘: q, ‘limit‘: limit, ‘offset‘: offset}
A few key behaviors I rely on:
Query(...)with...makes the parameter required.- Regular defaults make the parameter optional.
- Constraints such as
ge,le,minlength, andmaxlengthare enforced before your code runs. - Metadata like
descriptionandexampleshows up in generated docs.
If I want to keep the signature clean and still apply constraints, I use Annotated:
from typing import Annotated
from fastapi import FastAPI, Query
app = FastAPI()
@app.get(‘/search‘)
def search(q: Annotated[str, Query(min_length=2)]):
return {‘q‘: q}
This style keeps the type and the validation logic in one place without cluttering the default value.
Required vs optional: defaults as API signals
In FastAPI, the distinction between required and optional is extremely explicit. If you want a parameter to be required, do not give it a default. If you want it to be optional, give it a default. If you want it to be optional but still validated, use Query(None, ...).
I usually make optional parameters default to None and then interpret them inside the handler. That leaves space for valid falsy values like 0 or False.
from fastapi import FastAPI, Query
app = FastAPI()
@app.get(‘/reports‘)
def reports(
start: str | None = Query(None, description=‘Start date in ISO format‘),
end: str | None = Query(None, description=‘End date in ISO format‘),
):
return {‘start‘: start, ‘end‘: end}
Two edge cases I plan for:
?q=sends an empty string, notNone. If empty strings should behave like missing values, I normalize them explicitly.?limit=0is different from a missinglimit. I only treatNoneas missing.
That clarity makes both server and client behavior more predictable.
Validation and constraints with Query
The Query helper is where FastAPI really shines for query parameters. I treat it like a contract language. I describe the shape, limits, and intent in one place, then let FastAPI enforce it.
from fastapi import FastAPI, Query, HTTPException
app = FastAPI()
@app.get(‘/products‘)
def list_products(
min_price: float | None = Query(None, ge=0),
max_price: float | None = Query(None, ge=0),
):
if minprice is not None and maxprice is not None and minprice > maxprice:
raise HTTPException(statuscode=400, detail=‘minprice cannot exceed max_price‘)
return {‘minprice‘: minprice, ‘maxprice‘: maxprice}
This example shows two layers of validation:
- Field-level validation with
ge=0to prevent negative values. - Cross-field validation in code to keep parameters consistent.
Other constraints I use often:
minlengthandmaxlengthfor user input.minitemsandmaxitemsfor lists.aliasto support older client parameter names.deprecated=Truewhen I plan to remove a parameter later.
I avoid deep validation logic in the handler if it can be expressed with Query. It keeps errors uniform and the handler focused on business logic.
Types that feel good in practice
The type system is not just nice-to-have. It is the easiest way to make query parameters self-documenting.
Here are types I reach for in real APIs:
intandfloatfor counts and simple ranges.Decimalfor money values where precision matters.boolfor flags likeinclude_archivedorverbose.dateanddatetimefor time-based filtering.UUIDfor identifiers that should not be guessable.Enumfor controlled choices.
A concrete example:
from datetime import date
from uuid import UUID
from fastapi import FastAPI
app = FastAPI()
@app.get(‘/events‘)
def list_events(
on: date | None = None,
organizer_id: UUID | None = None,
include_cancelled: bool = False,
):
return {
‘on‘: on,
‘organizerid‘: organizerid,
‘includecancelled‘: includecancelled,
}
I keep the parsing rules simple and consistent. For booleans, I stick to common forms like true and false in docs. For dates, I ask clients to send ISO-formatted dates to avoid ambiguity. If time zones matter, I document that explicitly and test a few edge cases.
Lists and multi-valued query parameters
Multi-value filters are extremely common, and FastAPI handles them cleanly.
from fastapi import FastAPI, Query
app = FastAPI()
@app.get(‘/items‘)
def list_items(
tag: list[str] | None = Query(None),
status: list[str] | None = Query(None),
):
return {‘tag‘: tag, ‘status‘: status}
Clients can call /items?tag=python&tag=fastapi&status=active and FastAPI will parse tag as a list. A few practical considerations:
- I document that repeated keys represent multiple values.
- I normalize empty lists to
Noneif the backend treats missing and empty the same. - If I need comma-separated values, I parse them manually. I do not assume comma parsing unless I implement it.
This pattern scales nicely for filters like categories, labels, or permissions.
Enums and constrained choices
When I want clients to choose from a fixed set of values, I use Enum. It improves docs and validation at the same time.
from enum import Enum
from fastapi import FastAPI
class SortBy(str, Enum):
price = ‘price‘
rating = ‘rating‘
createdat = ‘createdat‘
app = FastAPI()
@app.get(‘/products‘)
def listproducts(sort: SortBy = SortBy.createdat):
return {‘sort‘: sort}
Enums make bad values fail fast with a clear 422 error. They also surface choices in the docs. I prefer enums over string validation because the allowed set stays close to the code.
Pagination patterns that stay sane
Pagination is where query parameters pay off the most. I usually pick one of two patterns and stick to it consistently:
limitandoffsetfor simple, stable ordering.pageandsizefor more user-friendly interfaces.
limit and offset is the most direct. It maps cleanly to databases and is easy to enforce with bounds.
from fastapi import FastAPI, Query
app = FastAPI()
@app.get(‘/logs‘)
def list_logs(
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
return {‘limit‘: limit, ‘offset‘: offset}
page and size is often easier for humans. I keep the transformation explicit:
from fastapi import FastAPI, Query
app = FastAPI()
@app.get(‘/logs‘)
def list_logs(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
):
offset = (page - 1) * size
return {‘page‘: page, ‘size‘: size, ‘offset‘: offset}
No matter which one I use, I always enforce a maximum. Unbounded pagination is one of the easiest ways to create performance issues and accidental denial of service.
Cursor-based pagination for deep datasets
Offset pagination can get slow as you move deeper into a dataset because the database still needs to scan or skip records. When I expect clients to page deeply, I switch to cursor-based pagination.
A simple pattern is cursor plus limit:
from fastapi import FastAPI, Query
app = FastAPI()
@app.get(‘/events‘)
def list_events(
cursor: str | None = Query(None),
limit: int = Query(20, ge=1, le=100),
):
return {‘cursor‘: cursor, ‘limit‘: limit}
The cursor is an opaque token the server understands, not a raw offset. I often base it on the last seen id or timestamp. I document how clients should treat it: store it, pass it back, and do not try to interpret it. The goal is to make deep pagination predictable and performant without exposing internal details.
Sorting without opening security holes
Sorting often sounds simple, but it is a common source of security and performance issues. I never pipe a user-provided sort parameter directly into a SQL statement. Instead, I map allowed values to known columns or fields.
Here is a minimal pattern:
from fastapi import FastAPI, Query, HTTPException
app = FastAPI()
ALLOWEDSORTS = {‘price‘, ‘rating‘, ‘createdat‘}
def normalize_sort(sort: str | None) -> str:
if sort is None:
return ‘created_at‘
if sort.lstrip(‘-‘) not in ALLOWED_SORTS:
raise HTTPException(status_code=400, detail=‘Invalid sort field‘)
return sort
@app.get(‘/products‘)
def list_products(sort: str | None = Query(None)):
sort = normalize_sort(sort)
return {‘sort‘: sort}
I also keep the sort syntax simple. A common pattern is -field for descending. If I need multi-field sorting, I accept a list and apply the same allowlist check.
Filtering patterns that stay readable
Filtering is where teams often create a lot of endpoints. I prefer one endpoint with well-scoped query parameters.
For numeric ranges, I use paired parameters:
from fastapi import FastAPI, Query, HTTPException
app = FastAPI()
@app.get(‘/products‘)
def list_products(
min_price: float | None = Query(None, ge=0),
max_price: float | None = Query(None, ge=0),
):
if minprice is not None and maxprice is not None and minprice > maxprice:
raise HTTPException(statuscode=400, detail=‘minprice cannot exceed max_price‘)
return {‘minprice‘: minprice, ‘maxprice‘: maxprice}
For categorical filters, I use repeated query keys:
/products?category=books&category=games/products?status=active
For booleans, I keep the naming explicit:
in_stock=trueinclude_archived=false
If the filter set starts to sprawl, I group them into a dedicated dependency to keep the handler readable.
from fastapi import FastAPI, Depends
from pydantic import BaseModel, Field
class ProductFilters(BaseModel):
min_price: float | None = Field(None, ge=0)
max_price: float | None = Field(None, ge=0)
category: list[str] | None = None
in_stock: bool | None = None
app = FastAPI()
@app.get(‘/products‘)
def list_products(filters: ProductFilters = Depends()):
return {‘filters‘: filters}
This pattern scales because you can add fields to the model without making the endpoint signature unwieldy.
Response shaping with fields and expand
Query parameters are also great for controlling response shape. Two patterns I use often are fields and expand.
fieldslets clients request a subset of attributes.expandlets clients include related data in a single request.
I always allowlist fields, otherwise clients can request sensitive data by mistake. I also keep the behavior consistent: if fields is missing, return the default shape; if it is present, return only those fields.
This is a place where query parameters shine because the resource stays the same, but the representation changes based on what the client actually needs.
Search parameters that do not turn into a mini-language
Search is often the first place where query parameters get overloaded. I avoid building a custom query language inside a q parameter unless I absolutely need it. Instead, I keep search inputs simple and explicit:
qfor a general text search.fieldsfor which fields to search.matchfor the match mode, such asexactorprefix.
If the search syntax gets complex, I switch to a request body and document it as a search object. That helps keep URLs readable and avoids length limits.
Aliases and backward compatibility
Renaming a query parameter is a breaking change for clients. When I need to evolve names, I often accept both the old and new names for a while.
FastAPI lets me do that with alias:
from fastapi import FastAPI, Query
app = FastAPI()
@app.get(‘/search‘)
def search(q: str | None = Query(None, alias=‘query‘)):
return {‘q‘: q}
In this example, clients can send ?query=phone, but inside the handler I still use q. I also use deprecated=True on the old name to signal future removal in the docs. That gives clients a clear migration path.
Error handling and client experience
One of the most practical benefits of FastAPI query parameters is the automatic error response. If a parameter fails validation, FastAPI returns a 422 with details pointing to the parameter, the error message, and the error type. Clients can act on that without needing to parse a custom error format.
I still raise explicit errors when the issue is a business rule, not a format issue. For example, minprice greater than maxprice is not a parsing error; it is a logical inconsistency. That should be a 400 with a clear message.
The split between 422 for validation and 400 for logical errors keeps error handling consistent.
Performance and safety considerations
Query parameters are not free. They shape what the backend does, and they can become the easiest path to expensive queries if I am not careful. A few guardrails I apply consistently:
- Always enforce maximums for pagination and list sizes.
- Allowlist sortable fields and filterable columns.
- Require indexes for commonly filtered fields before launching a new filter.
- Avoid leading wildcard searches on large text fields unless there is a search index.
- Keep expensive joins or aggregations behind explicit flags.
In practice, the difference is dramatic. Adding an index can move a filtered query from hundreds of milliseconds into the tens-of-milliseconds range, while an unbounded scan can move from tens of milliseconds into multi-second territory under load. I use ranges instead of promises here because it depends on data size and hardware, but the direction is consistent: bounded and indexed beats unbounded every time.
Privacy and logging
Query parameters often end up in logs, metrics, and analytics by default. That is convenient for debugging but risky for sensitive data. I avoid putting secrets, personal data, or long free-form text into query parameters unless I have a logging strategy that redacts or hashes them.
If I must accept sensitive input, I prefer a request body and explicit logging rules. This is not just a security consideration; it affects compliance and operational safety.
Caching and idempotency
GET requests with query parameters are naturally cacheable if the response only depends on the URL. That makes consistent parameter handling even more important. I keep a few rules in mind:
- Default values should match implicit behavior. If
limitdefaults to 20, the server should behave the same whether the client includes it or not. - Order should not matter.
/items?tag=a&tag=band/items?tag=b&tag=ashould return the same result if order is not meaningful. - If the API is cache-sensitive, I normalize parameters before building cache keys.
These small decisions affect CDN behavior and client caching, which is why I treat query parameter design as part of performance planning.
Edge cases: encoding, empty values, URL length
Three edge cases come up over and over:
- URL encoding. Spaces, plus signs, and special characters should be encoded by clients. If you accept raw text inputs, test how they appear after decoding.
- Empty values.
?q=is not the same as missingq. Decide how you want to interpret empty strings and document it. - URL length limits. Very long query strings can be rejected by proxies or browsers. If your filters or search inputs are large, move them to the request body.
These are easy to miss in development and painful to debug in production, so I try to cover them in tests.
Common pitfalls I see in the wild
I have made all of these mistakes at least once, and I now watch for them:
- Overloading a single parameter to mean multiple things.
- Using
strfor values that should be constrained or typed. - Forgetting to add maximum limits for pagination or lists.
- Treating query parameters as private, then leaking them into logs or analytics.
- Accepting dynamic field names for sorting and filtering, leading to SQL injection risks.
- Mixing path parameters and query parameters so the same concept appears in both places.
If I see any of those in a code review, I treat it as a design issue, not a style issue.
When not to use query parameters
Query parameters are not a universal answer. I avoid them when:
- The input is large or deeply nested.
- The input is sensitive and should not appear in logs or caches.
- The filter language is complex and needs a structured representation.
In those cases, I move the filter into the request body, even for a GET-like operation, or I switch to a POST endpoint like /search. I also consider whether the API would be better served by a dedicated search system or a GraphQL layer.
Here is a comparison that I often share with teams:
When I use it
—
Simple, distinct resources
Light to moderate filtering and sorting
Complex filters or large payloads
The point is not that query parameters are always best. The point is that they are best when the input is small, optional, and about shaping the result.
Testing query parameters
Query parameters are easy to test, and I recommend doing so. Tests catch edge cases like missing required params or type mismatches.
from fastapi.testclient import TestClient
from fastapi import FastAPI, Query
app = FastAPI()
@app.get(‘/search‘)
def search(q: str = Query(..., min_length=2)):
return {‘q‘: q}
client = TestClient(app)
def testsearchrequires_q():
resp = client.get(‘/search‘)
assert resp.status_code == 422
def testsearchmin_length():
resp = client.get(‘/search‘, params={‘q‘: ‘a‘})
assert resp.status_code == 422
def testsearchok():
resp = client.get(‘/search‘, params={‘q‘: ‘fastapi‘})
assert resp.json() == {‘q‘: ‘fastapi‘}
I keep these tests narrow and focused. They are fast and give me confidence that the contract stays intact.
Tooling and AI-assisted workflows
FastAPI generates an OpenAPI schema automatically, and that schema includes query parameter types, constraints, and descriptions. I use it to generate client SDKs, validate requests in tests, and document changes.
When I use AI-assisted workflows, I focus on two areas:
- Generating a parameter matrix of valid and invalid combinations for tests.
- Drafting docs from the OpenAPI schema, then editing for clarity.
Validation note: this is a content-only expansion, so I did not run commands here. The snippets are written as runnable templates you can execute in your environment.
Production considerations: monitoring, scaling, and evolution
In production, query parameters are part of operational reality. A few practices keep me out of trouble:
- Track usage of parameters so you can deprecate unused ones safely.
- Log query parameter summaries, not raw data, if inputs can contain sensitive values.
- Add rate limits for endpoints with expensive filters.
- Set clear deprecation windows when renaming parameters.
These steps are not glamorous, but they prevent surprises as the API scales.
Putting it all together: a realistic endpoint
Here is a more complete example that shows how I combine filters, sorting, and pagination in a single endpoint. It uses allowlists for safety and keeps the handler readable.
from enum import Enum
from fastapi import FastAPI, Query, Depends, HTTPException
from pydantic import BaseModel, Field
class SortBy(str, Enum):
price = ‘price‘
rating = ‘rating‘
createdat = ‘createdat‘
class ProductFilters(BaseModel):
min_price: float | None = Field(None, ge=0)
max_price: float | None = Field(None, ge=0)
category: list[str] | None = None
in_stock: bool | None = None
app = FastAPI()
ALLOWEDSORTS = {‘price‘, ‘rating‘, ‘createdat‘}
def normalize_sort(sort: str | None) -> str:
if sort is None:
return ‘created_at‘
if sort.lstrip(‘-‘) not in ALLOWED_SORTS:
raise HTTPException(status_code=400, detail=‘Invalid sort field‘)
return sort
@app.get(‘/products‘)
def list_products(
filters: ProductFilters = Depends(),
sort: str | None = Query(None, description=‘Field to sort by, prefix with - for desc‘),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
sort = normalize_sort(sort)
if filters.minprice is not None and filters.maxprice is not None:
if filters.minprice > filters.maxprice:
raise HTTPException(statuscode=400, detail=‘minprice cannot exceed max_price‘)
# In a real app, you would translate filters into a database query
return {
‘filters‘: filters,
‘sort‘: sort,
‘limit‘: limit,
‘offset‘: offset,
}
This example does a few things I care about:
- It validates inputs before any query hits the database.
- It keeps parameter logic close to the endpoint, so the contract is visible.
- It uses allowlists and bounds to prevent abuse.
If I need to evolve this endpoint later, I can add parameters to ProductFilters or adjust the sort allowlist without changing the route itself.
Design checklist for query parameters
When I review an endpoint, I run through this checklist:
- Are required parameters truly required, or should they be optional?
- Are optional parameters given safe defaults with clear bounds?
- Are sorting and filtering fields allowlisted?
- Do query parameters map cleanly to indexed fields or efficient computations?
- Are sensitive inputs avoided or handled carefully in logs?
- Are deprecated parameters marked and documented?
If I can answer these with confidence, I know the endpoint will scale in both usage and maintenance.


