← Back to the section

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

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:

SituationWhat to choose
Flat fields (status, date)GET
Nested objectsPOST /search
Array of 10+ values in a single filterPOST /search
AND/OR combinationsPOST /search
The query needs to be savedPOST /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 zeropage=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; StrEnum limits the allowed values; ranges use From/To (inclusive).
  • Offset pagination: page (starting from one) + size; the response contains totalElements and totalPages.
  • Cursor pagination: the client passes an opaque token from the nextCursor field 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/search with a Pydantic body and a 200 OK response.
  • 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-Key for POST /search.