← назад к разделу

Query-параметры — это то, что идёт в URL после вопросительного знака: ?status=CREATED&page=2. В FastAPI они объявляются прямо в сигнатуре функции, а специальный класс Query позволяет настроить их поведение. Разберём с нуля: как назвать параметры, как строить фильтры, постраничную навигацию и поиск.

Имена параметров: почему нужен alias

В Python принято называть переменные в стиле snake_case — например, customer_id. Но в URL REST API принят camelCase — customerId. Это требование не связано с Python, оно относится к самому API-контракту, который используют клиенты.

FastAPI не конвертирует имена автоматически. Если написать:

async def get_orders(customer_id: str | None = None):
    ...

то клиент должен передавать ?customer_id=123 — со snake_case в URL. Это неправильно.

Решение — параметр alias в 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"),
):
    ...

Теперь в URL пишут ?customerId=123, а внутри функции работают с Python-переменной customer_id. Все довольны.

GET /api/v1/orders?customerId=123&dateFrom=2026-01-01     ✓
GET /api/v1/orders?customer_id=123                        ✗ — snake_case в URL
GET /api/v1/orders?CustomerID=123                         ✗ — PascalCase

Фильтры и диапазоны

Фильтр по конкретному значению — просто дополнительный параметр. Если значения ограничены заранее известным набором, используют перечисление (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"),
):
    ...

Для диапазонов используют суффиксы From/To. Оба конца — включительно: ?amountFrom=100&amountTo=500 означает «от 100 до 500 включительно».

Постраничная навигация (offset-based)

Самый распространённый способ — указать номер страницы и размер страницы. Важная деталь: страницы нумеруются с единицы, не с нуля.

@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 — первая страница. Параметр ge=1 не позволяет передать ноль или отрицательное число.

Ответ возвращает данные вместе с информацией о пагинации:

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
}

Частая ошибка — нумерация с нуля (page=0 для первой страницы). Это ломает контракт: клиенты ожидают page=1.

Cursor-пагинация

Offset-пагинация плохо работает с большими объёмами данных и быстро меняющимися списками: пока пользователь листает, вставляются новые записи, и страницы «сдвигаются». Cursor-пагинация решает эту проблему.

Вместо номера страницы клиент передаёт непрозрачный токен (cursor), который сервер сам формирует и сам же декодирует. Клиент его не парсит — просто берёт из ответа и передаёт в следующем запросе.

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()

Формат ответа:

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)

Клиент берёт значение из nextCursor и передаёт в следующий запрос как ?cursor=.... Внутреннее устройство токена — дело сервера.

Сортировка

Параметр sort принимает название поля и направление через запятую. Можно передать несколько значений — каждое отдельным параметром:

@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

Полнотекстовый поиск

Для поиска по тексту используют параметр q:

@router.get("/products")
async def search_products(q: str | None = Query(default=None)):
    ...

Множественные значения

Когда нужно передать несколько значений одного фильтра, используют повтор параметра:

GET /api/v1/orders?status=CREATED&status=CONFIRMED     ✓
GET /api/v1/orders?status=CREATED,CONFIRMED            ✗ — через запятую

В FastAPI это объявляется через list[T]:

@router.get("/orders")
async def get_orders(
    status: list[OrderStatus] = Query(default=[]),
):
    ...

FastAPI автоматически генерирует правильный OpenAPI-контракт (style: form, explode: true). Вариант через запятую — частая ошибка: он требует ручного парсинга и ломается, если в самом значении есть запятая.

Когда нужен POST /search

GET-запрос хорошо работает для плоских фильтров. Но если нужны вложенные объекты, массивы из десятков значений или логика AND/OR — URL становится неудобным и может превысить допустимую длину.

В таких случаях используют отдельный эндпоинт POST /resources/search с Pydantic-телом:

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:
    ...

Ориентир для выбора:

СитуацияЧто выбрать
Плоские поля (status, date)GET
Вложенные объектыPOST /search
Массив из 10+ значений в одном фильтреPOST /search
AND/OR-комбинацииPOST /search
Запрос нужно сохранятьPOST /search

Путь — /resources/search (не /query, не /find). Ответ всегда 200 OK, формат — такой же, как у GET /resources.

Частые ошибки

snake_case в URL — параметр customer_id без alias приводит к ?customer_id=... в URL. Правильно: Query(alias="customerId").

Нумерация страниц с нуляpage=0 для первой страницы сбивает клиентов с толку. Правильно: page=1.

Запись действия в query?action=cancel вместо нормального эндпоинта. Правильно: POST /orders/{id}/cancel.

Декодирование cursor на клиенте — cursor непрозрачен для клиента. Его содержимое может измениться, это не часть контракта.

Коротко

  • Query-параметры в FastAPI объявляют в сигнатуре функции; Query(alias="camelCase") задаёт имя в URL.
  • Фильтры — дополнительные параметры; StrEnum ограничивает допустимые значения; диапазоны через From/To (включительно).
  • Offset-пагинация: page (с единицы) + size; ответ содержит totalElements и totalPages.
  • Cursor-пагинация: клиент передаёт непрозрачный токен из поля nextCursor предыдущего ответа — не декодирует.
  • Множественные значения — повторением параметра (?status=A&status=B), не через запятую.
  • Для сложных запросов с вложенной структурой или длинными массивами — POST /resources/search с Pydantic-телом и ответом 200 OK.

Что почитать дальше

  • URL и ресурсы — как строить пути эндпоинтов.
  • JSON и формат ответов — структура ответа с пагинацией, настройка model_config.
  • Заголовки и трассировка — Idempotency-Key для POST /search.