Logo
Overview

Пагинация, фильтрация и сортировка в REST API: как проектировать выборки

October 23, 2026
8 min read

Когда эндпоинт возвращает 10 записей — ни о какой пагинации не думаешь. Просто SELECT * FROM orders и отдал JSON’ом. Но проходит месяц. Orders набирает 50 000 записей. GET /orders начинает отвечать по 3 секунды, база стонет, а фронтенд рендерит бесконечный скролл на 10 мегабайт данных. Клиент в мобильной сети ждёт полминуты.

Я наступал на эти грабли дважды. Первый раз — когда мы просто добавили ?page=1&limit=50 задним числом и сломали всех, кто парсил ответ как «весь список». Второй раз — когда cursor-пагинацию прикрутили к отчёту, который никто не сортировал, и курсоры стали ссылаться в пустоту. Разберу, как делать нормально.

Если с правилами названий ручек вы уже знакомы — пагинация, фильтрация и сортировка следующий логичный шаг к взрослому API.

Offset-пагинация: простая, но с подвохом

Самый массовый способ. Клиент говорит «дай страницу 3 по 20 записей», сервер считает OFFSET 40 LIMIT 20:

GET /orders?page=3&limit=20

В ответе, кроме самого массива данных, нужны метаданные. Как минимум — общее количество записей, чтобы фронтенд мог нарисовать «Страница 3 из 47»:

{
"data": [ ... ],
"meta": {
"page": 3,
"limit": 20,
"total": 934,
"pages": 47
}
}

Плюс — Link-заголовки для навигации. Да, этот RFC читают реже, чем хотелось бы, но он решает проблему HATEOAS без переусложнения:

Link: </orders?page=1&limit=20>; rel="first",
</orders?page=2&limit=20>; rel="prev",
</orders?page=4&limit=20>; rel="next",
</orders?page=47&limit=20>; rel="last"

Проблема offset-пагинации не в реализации. Она в том, что происходит между запросом страницы 1 и страницы 2.

Представьте: пользователь читает первую страницу заказов. В этот момент кто-то создал новый заказ, и он встал первым в списке (сортировка по дате создания). Пользователь жмёт «Далее». Сервер делает OFFSET 20 LIMIT 20, но из-за вставки нового заказа строка, которая была 20-й, стала 21-й — и пользователь видит её повторно на второй странице. Дубликат. Наоборот тоже бывает: строка была 21-й, стала 20-й из-за удаления — и пользователь её пропускает.

Для админок, отчётов и внутренних тулов это терпимо. Для ленты новостей, где пользователь скроллит и видит одни и те же посты через каждые 20 записей — бесит невероятно.

Cursor-пагинация: без дубликатов, но без страниц

Здесь нет понятия «страница 3». Клиент получает курсор — непрозрачную строку, которая кодирует позицию в наборе — и передаёт её для следующего запроса:

GET /orders?cursor=eyJpZCI6MTAsImNyZWF0ZWRfYXQiOiIyMDI2LTA5LTE1In0&limit=20

Ответ:

{
"data": [ ... ],
"meta": {
"limit": 20,
"next_cursor": "eyJpZCI6MzAsImNyZWF0ZWRfYXQiOiIyMDI2LTA4LTAxIn0",
"has_more": true
}
}

Курсор — обычно base64-encoded JSON с полями сортировки последней записи страницы. Сервер берёт его, декодит и делает WHERE (created_at, id) < ('2026-08-01', 30) ORDER BY created_at DESC, id DESC LIMIT 20. Новая запись между страницами не ломает выдачу: условие WHERE привязано к конкретным значениям, а не к позиции.

Цена: нет общего количества (total), нет номеров страниц, нет кнопки «перейти на страницу 47». Клиенту доступна только последовательная навигация вперёд (и назад, если сервер отдаёт prev_cursor). Для infinite scroll’а ленты, чатов, real-time-стримов — идеально. Для дашборда с таблицей и фильтрами, где бухгалтер хочет прыгнуть на страницу 14 — мучение.

Keyset-пагинация: компромисс

Гибрид между offset и cursor. Вместо непрозрачного курсора клиент явно передаёт значение поля, по которому сортируется выдача:

GET /orders?after_id=42&limit=20&sort=id

Сервер делает WHERE id > 42 ORDER BY id ASC LIMIT 20. Прозрачно, предсказуемо, не ломается от вставок. Но работает только если сортировка идёт по одному полю и это поле уникально.

Подходит для простых CRUD-справочников, где id монотонно растёт и сортировка всегда по нему. Как только нужна сортировка «сначала дорогие заказы» или «по дате доставки» — keyset сыплется: поле total может повторяться, и условие WHERE total < 1000 пропустит второй заказ с той же суммой.

Какую стратегию выбрать

Вместо таблицы — дерево решений. Проходите от корня по стрелкам в зависимости от вашего контекста.

100%
graph TD
  START["Выбор стратегии пагинации"] --> Q1{"Данные часто меняются?"}
  Q1 -->|"Нет — статичный список"| OFFSET["Offset-пагинация
?page=2&limit=20"]
  Q1 -->|"Да — частые вставки/удаления"| Q2{"Нужна сортировка
по нескольким полям?"}
  Q2 -->|"Да"| CURSOR["Cursor-пагинация
?cursor=abc123&limit=20"]
  Q2 -->|"Нет"| Q3{"Достаточно одного
стабильного поля?"}
  Q3 -->|"Да"| KEYSET["Keyset-пагинация
?after_id=42&limit=20"]
  Q3 -->|"Нет"| CURSOR
  style START fill:#7b68ee,stroke:#5a4db2,color:#fff
  style Q1 fill:#f0a500,stroke:#c88400,color:#fff
  style Q2 fill:#f0a500,stroke:#c88400,color:#fff
  style Q3 fill:#f0a500,stroke:#c88400,color:#fff
  style OFFSET fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style CURSOR fill:#50c878,stroke:#3a9a5c,color:#fff
  style KEYSET fill:#50c878,stroke:#3a9a5c,color:#fff

Жёлтые ромбы — развилки. Синие — offset (простой, но с дубликатами на изменяющихся данных). Зелёные — стратегии, устойчивые к изменениям. Если сомневаетесь — берите cursor. Переделать cursor в offset потом можно, наоборот — ломаете контракт.

Честно говоря, 90% проектов прекрасно живут на offset-пагинации. Проблема дубликатов реальна, но пользователи жалуются на неё раз в полгода, а трудозатраты на cursor с нуля — минимум спринт. Если у вас внутренняя админка — не парьтесь, offset достаточно.

Фильтрация: синтаксис, который не превращает URL в монстра

Правила названий ручек дают читаемый URL. Фильтрация может всё испортить.

Плохо:

GET /orders?statusFilter=active&orderMinTotal=100&lastCreatedFrom=2026-01-01

Такое невозможно запомнить. Каждый параметр придуман с нуля, префиксы неконсистентны (Min, From, Filter означают одно и то же — ограничение).

Хорошо:

GET /orders?status=active&min_total=100&created_at[gte]=2026-01-01&created_at[lte]=2026-10-01

Правила здесь простые:

  • Имя параметра = имя поля в модели данных (status, total, created_at)
  • Для диапазонов — суффиксы-операторы: [gte], [lte], [gt], [lt], реже [eq], [ne], [in]
  • Множественный выбор: status=active,pending (запятая как разделитель) или status[]=active&status[]=pending (массив через квадратные скобки)

Операторная нотация (field[op]=value) позаимствована из LHS Brackets — неофициального, но де-факто стандарта, который понимает большинство библиотек парсинга query-параметров на бэкенде. Альтернатива — filter[field][op]=value — переусложняет URL без выигрыша, если у вас не десяток вложенных ресурсов.

Ещё одна крайность — полноценный язык запросов в query-строке:

GET /orders?q=status:active AND total>=100 AND created_at>=2026-01-01

Выглядит мощно. На практике превращается в отдельный мини-проект: парсер, лексер, обработка ошибок синтаксиса, инъекции. Я участвовал в проекте, где такой q жил полгода, а потом его молча снесли, заменив плоскими параметрами — потому что каждое новое поле требовало доработки парсера и документации.

Для публичного API держите фильтры плоскими. Для внутренних аналитических дашбордов можно позволить DSL-подобный синтаксис — но только если есть ресурс его поддерживать.

Сортировка: порядок полей имеет значение

Минимальный синтаксис:

GET /orders?sort=-created_at

Минус — по убыванию, плюс или без префикса — по возрастанию. Сортировка по нескольким полям — через запятую:

GET /orders?sort=-created_at,+status

Это читается как «сначала свежие, затем по статусу от A до Z». Порядок полей в sort определяет приоритет: первое поле — первичная сортировка, второе — вторичная (при равных значениях первого).

Вариант с раздельными параметрами — ?sort_by=created_at&sort_order=desc — выглядит аккуратнее для одного поля, но ломается, когда полей два и больше: как передать, что первичная сортировка created_at desc, а вторичная status asc? Приходится городить ?sort_by[]=created_at&sort_order[]=desc&sort_by[]=status&sort_order[]=asc — и надеяться, что клиент не перепутает индексы.

Лично я пользуюсь синтаксисом с запятой. Он компактный, его гуглят, и клиенты не спорят.

Как это выглядит вместе: полный запрос на выборку

100%
sequenceDiagram
  participant Client as Клиент
  participant API as REST API
  participant DB as База данных
  Client->>API: GET /orders?page=1&limit=3&sort=-created_at
  API->>DB: SELECT * FROM orders ORDER BY created_at DESC LIMIT 3 OFFSET 0
  DB-->>API: [{id:10}, {id:9}, {id:8}]
  API-->>Client: 200 OK
  Note over DB: Вставили заказ id=11
  Client->>API: GET /orders?page=2&limit=3
  API->>DB: SELECT * FROM orders ORDER BY created_at DESC LIMIT 3 OFFSET 3
  DB-->>API: [{id:8}, {id:7}, {id:6}]
  Note over Client,API: Дубликат! id=8 уже был на стр.1
  API-->>Client: 200 OK (дубликат)
  Note over Client,DB: Cursor-подход решает проблему
  Client->>API: GET /orders?cursor=eyJpZCI6OH0&limit=3
  API->>DB: SELECT * FROM orders WHERE id < 8 ORDER BY id DESC LIMIT 3
  DB-->>API: [{id:7}, {id:6}, {id:5}]
  API-->>Client: 200 OK

На схеме — проблема offset-пагинации в чистом виде. Между страницами вставили заказ — и id=8 съехал, появившись на обеих страницах. Cursor эту проблему обходит: запрос с WHERE id < 8 стартует строго от последней видимой записи, независимо от того, что вставилось выше.

Полный запрос с фильтрацией и сортировкой может выглядеть так:

GET /orders?status=active,pending&min_total=100&created_at[gte]=2026-01-01&sort=-created_at,+id&page=1&limit=20

Между этим URL и его отсутствием — примерно год эволюции API. Сначала появляется пагинация, потому что данных стало много. Потом фильтрация — потому что клиентам надоело скроллить 47 страниц в поисках активных заказов. Потом сортировка — потому что дефолтный порядок по id перестал устраивать. Потом поддержка нескольких полей сортировки — потому что менеджеры хотят «сначала по дате, потом по сумме».

Не надо проектировать всё это сразу, если у вас 200 записей в таблице. Но когда API растёт — каждое из этих расширений должно ложиться в общую схему, а не плодить ad-hoc параметры.

Как это превращается в бэкенд-запрос

100%
graph LR
  REQ["HTTP-запрос
GET /orders?..."] --> PARSE["Парсинг query-параметров"]
  PARSE --> FILTER["Фильтрация
?status=active&min_total=100"]
  PARSE --> SORT["Сортировка
?sort=-created_at,+id"]
  PARSE --> PAGE["Пагинация
?page=1&limit=20 или ?cursor=xyz"]
  FILTER --> DB["Генерация SQL
WHERE status='active' AND total >= 100"]
  SORT --> DB
  PAGE --> DB
  DB --> RESULT["Результат
200 OK + Link-заголовки"]
  style REQ fill:#7b68ee,stroke:#5a4db2,color:#fff
  style PARSE fill:#f0a500,stroke:#c88400,color:#fff
  style FILTER fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style SORT fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style PAGE fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style DB fill:#50c878,stroke:#3a9a5c,color:#fff
  style RESULT fill:#e0e0e0,stroke:#999,color:#333

Три независимых оси обработки — фильтрация, сортировка, пагинация — собираются в один SQL-запрос на стороне базы. Это важно спроектировать на уровне контракта API, чтобы бэкенд-разработчику не приходилось изобретать свой FancyQueryBuilder для каждого эндпоинта.

Отдельный момент — безопасность. Если параметр фильтра sort напрямую подставляется в ORDER BY, ждите ?sort=(CASE WHEN ...) и SQL-инъекцию. Все поля для фильтрации и сортировки должны валидироваться по белому списку разрешённых колонок. Неприятно, да. Но ровно так случилось в одном проекте, где я консультировал: аналитик спроектировал гибкую фильтрацию, бэкендер быстро накидал на ORM без валидации, и через месяц пентест принёс инцидент.

Антипаттерны и частые ошибки

Собрал то, на чём спотыкался сам или видел у коллег.

Пагинация без total. Отдаёте ?page=1&limit=20 и массив из 20 записей. Клиент не знает, есть ли страница 2. Делает запрос — пустой массив. Делает ещё один — пусто. Клиент уходит в бесконечный цикл из вежливости. Всегда отдавайте has_more (для cursor) или total/pages (для offset).

Сортировка по полю, которого нет в индексе базы. Сделали ?sort=comment_text — база делает полное сканирование таблицы, время ответа выросло с 50 мс до 12 секунд. Проектируя параметры сортировки, сверьтесь с DBA: какие поля индексированы. Неиндексированные либо не добавляйте в sort, либо явно документируйте «медленно, до 10 секунд».

Фильтрация без учёта NULL. ?status=active исключает заказы с status IS NULL. Если половина legacy-заказов до сих пор без статуса — они просто исчезнут из выдачи. Явно документируйте, как фильтр обрабатывает NULL. Или добавьте специальное значение: ?status=null для поиска незаполненных полей.

Разные имена параметров для одного и того же. В одном эндпоинте min_price, в другом price_from, в третьем price[gte]. Если у вас больше трёх эндпоинтов — стандартизируйте именование фильтров на уровне код-стайла API. Версионирование API не поможет, если внутри одной версии бардак.

Пагинация без Link-заголовков. Без них клиент обязан сам собирать URL страниц, зная ваш синтаксис ?page= и ?limit=. Это жёсткая связка. Поменяли имена параметров — сломали навигацию у всех. RFC 8288 решает проблему элегантно: в заголовках приходят готовые URL, клиент просто переходит по ним.

Чек-лист для аналитика

При проектировании выборок зафиксируйте ответы на эти вопросы. Минимум полчаса на архитектурном ревью сэкономят.

  1. Какой тип пагинации? Offset — для админок и отчётов. Cursor — для лент и real-time. Keyset — для простых справочников с одним полем сортировки.
  2. Что в ответе кроме данных? total/pages для offset, next_cursor/has_more для cursor, Link-заголовки в обоих случаях.
  3. Как именуются параметры фильтрации? Единый стандарт на все эндпоинты: field[gte], min_field, или плоские — но что-то одно.
  4. Какие поля доступны для сортировки? Белый список, согласованный с DBA. Никакого свободного ORDER BY из query-параметра.
  5. Как обрабатываются NULL при фильтрации? Документировано явно. Или есть способ искать NULL.
  6. Сколько максимум записей за раз? limit должен иметь потолок. Без него ?limit=999999 — это отказ сервиса.
  7. Как клиент понимает, что данные кончились? Пустой массив — ОК. Но дублируйте это в has_more: false или page >= totalPages.
  8. Link-заголовки или хардкод URL на клиенте? Первое — гибкость. Второе — жёсткая связка.

PS. Если ваш API сейчас отдаёт GET /orders и возвращает всю таблицу — это не «пока так, потом доработаем». Это минус производительности, который копится с каждым днём. Добавьте хотя бы ?limit=100 с потолком и total в ответе. Остальное — потом.