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")
@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. Все довольны.
В новом коде тот же параметр чаще записывают через Annotated: customer_id: Annotated[str | None, Query(alias="customerId")] = None. Смысл тот же, а значение по умолчанию остаётся обычным значением по умолчанию Python, и такой параметр можно вынести в псевдоним типа и переиспользовать.
| Запрос | Годится | Почему |
|---|---|---|
GET /api/v1/orders?customerId=123&dateFrom=2026-01-01 | да | camelCase — то, что ждут в URL |
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, ConfigDict
from pydantic.alias_generators import to_camel
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 непрозрачен для клиента. Его содержимое может измениться, это не часть контракта.
Глубже: пределы и значения по умолчанию как часть контрактарасширенное
Всё, что не ограничено, однажды придёт максимальным. Клиент попросит size=100000, пришлёт фильтр с десятью тысячами идентификаторов и диапазон дат в десять лет, и не по злому умыслу, а потому что так получилось в цикле. Пределы это часть контракта, и в FastAPI они объявляются рядом с параметром:
async def list_orders(
size: Annotated[int, Query(ge=1, le=100)] = 20,
status: Annotated[list[OrderStatus], Query(max_length=10)] = [],
q: Annotated[str | None, Query(max_length=200)] = None,
): ...
le=100 режет размер страницы, max_length у списка ограничивает число значений (одиннадцатый status= даст 422, которую обработчик ошибок переводит в 400), max_length у строки длину поиска. Ширину диапазона дат проверяют валидатором: createdTo - createdFrom не больше 93 дней, иначе 400 RANGE_TOO_WIDE с подсказкой разбить запрос.
Значения по умолчанию такая же часть контракта: size=20, сортировка по createdAt по убыванию, период «последние 30 дней», если не задан. Они попадают в OpenAPI автоматически и должны быть одинаковыми у всех списков сервиса, иначе клиент учит их заново на каждом эндпоинте.
Что не ограничивают валидацией параметра: число результатов COUNT(*) для totalElements на таблице в сто миллионов строк. Его либо считают приблизительно, либо отдают только hasNext с курсорной пагинацией, об этом раздел выше.
Коротко
- 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. - Пределы — часть контракта:
Query(le=100)уsize,Query(max_length=...)у списков и строк, ширина диапазона дат в валидаторе; всё, что не ограничено, однажды придёт максимальным.
Что пощупать
Фильтр по цене в практикуме remodov/marketplace-system-python объявлен одной строкой: Query(None, alias="maxPrice", gt=0) с типом Decimal, так что мусор получает 400 с именем поля ещё до роутера. Репозиторий сравнивает Product._price <= max_price и сортирует по колонке в базе.
Код: router.py, repository.py.
Сделаем сами
Ветка step-02-read-endpoint - параметра нет, test_filter.py красный.
Что почитать дальше
- URL и ресурсы — как строить пути эндпоинтов.
- JSON и формат ответов — структура ответа с пагинацией, настройка
model_config. - Заголовки и трассировка —
Idempotency-Keyдля POST /search.