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.