Query parameters are what comes in the URL after the question mark: ?status=CREATED&page=2. In FastAPI they are declared right in the function signature, and a special Query class lets you configure their behavior. Let's go over it from scratch: how to name parameters, how to build filters, page-by-page navigation, and search.
Parameter names: why you need an alias
In Python, variables are conventionally named in snake_case — for example, customer_id. But in REST API URLs the convention is camelCase — customerId. This requirement has nothing to do with Python; it belongs to the API contract itself, which clients use.
FastAPI does not convert names automatically. If you write:
async def get_orders(customer_id: str | None = None):
...
then the client has to pass ?customer_id=123 — with snake_case in the URL. That's wrong.
The solution is the alias parameter in Query(...):
from fastapi import APIRouter, Query
router = APIRouter(prefix="/api/v1", redirect_slashes=False)
@router.get("/orders")
async def get_orders(
customer_id: str | None = Query(default=None, alias="customerId"),
date_from: str | None = Query(default=None, alias="dateFrom"),
date_to: str | None = Query(default=None, alias="dateTo"),
):
...
Now the URL uses ?customerId=123, while inside the function you work with the Python variable customer_id. Everyone's happy.
GET /api/v1/orders?customerId=123&dateFrom=2026-01-01 ✓
GET /api/v1/orders?customer_id=123 ✗ — snake_case in the URL
GET /api/v1/orders?CustomerID=123 ✗ — PascalCase
Filters and ranges
A filter for a specific value is just an extra parameter. If the values are limited to a known set defined in advance, use an enumeration (StrEnum):
from enum import StrEnum
class OrderStatus(StrEnum):
CREATED = "CREATED"
CONFIRMED = "CONFIRMED"
SHIPPED = "SHIPPED"
@router.get("/orders")
async def get_orders(
status: OrderStatus | None = Query(default=None),
customer_id: str | None = Query(default=None, alias="customerId"),
date_from: str | None = Query(default=None, alias="dateFrom"),
date_to: str | None = Query(default=None, alias="dateTo"),
amount_from: float | None = Query(default=None, alias="amountFrom"),
amount_to: float | None = Query(default=None, alias="amountTo"),
):
...
For ranges, use the From/To suffixes. Both ends are inclusive: ?amountFrom=100&amountTo=500 means "from 100 to 500 inclusive".
Page-by-page navigation (offset-based)
The most common approach is to specify a page number and a page size. An important detail: pages are numbered from one, not from zero.
@router.get("/orders")
async def get_orders(
page: int = Query(default=1, ge=1, alias="page"),
size: int = Query(default=20, ge=1, le=100, alias="size"),
):
offset = (page - 1) * size
...
page=1 is the first page. The ge=1 constraint prevents passing zero or a negative number.
The response returns the data together with pagination information:
from pydantic import BaseModel
class PaginatedOrders(BaseModel):
content: list[OrderResponse]
page: int
size: int
total_elements: int
total_pages: int
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
{
"content": [...],
"page": 1,
"size": 20,
"totalElements": 243,
"totalPages": 13
}
A common mistake is numbering from zero (page=0 for the first page). This breaks the contract: clients expect page=1.
Cursor pagination
Offset pagination works poorly with large volumes of data and rapidly changing lists: while the user is paging through, new records get inserted and the pages "shift". Cursor pagination solves this problem.
Instead of a page number, the client passes an opaque token (cursor), which the server both produces and decodes itself. The client doesn't parse it — it simply takes it from the response and passes it in the next request.
import base64, json
@router.get("/orders")
async def get_orders(
size: int = Query(default=20, ge=1, le=100),
cursor: str | None = Query(default=None),
):
decoded = None
if cursor:
decoded = json.loads(base64.b64decode(cursor))
...
def encode_cursor(last_id: str, last_created_at: str) -> str:
payload = {"id": last_id, "createdAt": last_created_at}
return base64.b64encode(json.dumps(payload).encode()).decode()
Response format:
class CursorPage(BaseModel):
content: list[OrderResponse]
size: int
next_cursor: str | None = None
prev_cursor: str | None = None
has_next: bool
has_prev: bool
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
The client takes the value from nextCursor and passes it into the next request as ?cursor=.... The token's internal structure is the server's business.
Sorting
The sort parameter takes a field name and a direction separated by a comma. You can pass several values — each as a separate parameter:
@router.get("/orders")
async def get_orders(
sort: list[str] = Query(default=["createdAt,desc"]),
):
...
GET /api/v1/orders?sort=createdAt,desc
GET /api/v1/orders?sort=totalAmount,asc&sort=createdAt,desc
Full-text search
For text search, use the q parameter:
@router.get("/products")
async def search_products(q: str | None = Query(default=None)):
...
Multiple values
When you need to pass several values for the same filter, use a repeated parameter:
GET /api/v1/orders?status=CREATED&status=CONFIRMED ✓
GET /api/v1/orders?status=CREATED,CONFIRMED ✗ — comma-separated
In FastAPI this is declared via list[T]:
@router.get("/orders")
async def get_orders(
status: list[OrderStatus] = Query(default=[]),
):
...
FastAPI automatically generates the correct OpenAPI contract (style: form, explode: true). The comma-separated variant is a common mistake: it requires manual parsing and breaks if the value itself contains a comma.
When you need POST /search
A GET request works well for flat filters. But when you need nested objects, arrays of dozens of values, or AND/OR logic, the URL becomes unwieldy and may exceed the allowed length.
In such cases, use a dedicated endpoint POST /resources/search with a Pydantic body:
from pydantic import BaseModel, Field
class OrderDateRange(BaseModel):
from_: str | None = Field(default=None, alias="from")
to: str | None = None
class CustomerFilter(BaseModel):
region_ids: list[int] = Field(default=[], alias="regionIds")
segment: str | None = None
class SortField(BaseModel):
field: str
direction: str = "DESC"
class OrderSearchRequest(BaseModel):
statuses: list[OrderStatus] = []
date_range: OrderDateRange | None = Field(default=None, alias="dateRange")
customer: CustomerFilter | None = None
sort: list[SortField] = Field(default=[SortField(field="createdAt")])
page: int = 1
size: int = 20
model_config = ConfigDict(populate_by_name=True)
@router.post(
"/orders/search",
status_code=200,
response_model=PaginatedOrders,
response_model_exclude_none=True,
)
async def search_orders(body: OrderSearchRequest) -> PaginatedOrders:
...
A guideline for choosing:
| Situation | What to choose |
|---|---|
| Flat fields (status, date) | GET |
| Nested objects | POST /search |
| Array of 10+ values in a single filter | POST /search |
| AND/OR combinations | POST /search |
| The query needs to be saved | POST /search |
The path is /resources/search (not /query, not /find). The response is always 200 OK, and the format is the same as for GET /resources.
Common mistakes
snake_case in the URL — the customer_id parameter without an alias leads to ?customer_id=... in the URL. Correct: Query(alias="customerId").
Numbering pages from zero — page=0 for the first page confuses clients. Correct: page=1.
Encoding an action in the query — ?action=cancel instead of a proper endpoint. Correct: POST /orders/{id}/cancel.
Decoding the cursor on the client — the cursor is opaque to the client. Its contents may change; it's not part of the contract.
In short
- Query parameters in FastAPI are declared in the function signature;
Query(alias="camelCase")sets the name in the URL. - Filters are extra parameters;
StrEnumlimits the allowed values; ranges useFrom/To(inclusive). - Offset pagination:
page(starting from one) +size; the response containstotalElementsandtotalPages. - Cursor pagination: the client passes an opaque token from the
nextCursorfield of the previous response — without decoding it. - Multiple values are passed by repeating the parameter (
?status=A&status=B), not comma-separated. - For complex queries with a nested structure or long arrays, use
POST /resources/searchwith a Pydantic body and a200 OKresponse.
What to read next
- URL and resources — how to build endpoint paths.
- JSON and response format — the structure of a paginated response, configuring
model_config. - Headers and tracing —
Idempotency-Keyfor POST /search.