Пагинация: offset против курсора — что выбрать
OFFSET проще внедрить, но на глубоких страницах заставляет базу данных просматривать и отбрасывать всё больше строк. Курсорная пагинация стабильнее под нагрузкой, однако требует правильной сортировки, индекса и обработки изменений данных.

Запрос с OFFSET 100000 может вернуть те же 50 записей, что и запрос с OFFSET 0, но перед этим базе придётся найти и пропустить большой объём строк. Курсорная пагинация решает эту проблему через условие «продолжить после последней записи», но взамен предъявляет требования к сортировке, индексам и формату API. Ниже — сравнение двух подходов на PostgreSQL 16 и критерии, по которым их стоит выбирать.
Сначала уточним, что именно сравниваем
В этой статье offset-пагинация означает запрос с LIMIT и OFFSET. Клиент передаёт номер страницы или количество пропущенных строк.
Курсорная пагинация, также называемая keyset pagination, не ищет страницу по номеру. Сервер получает позицию последней записи из предыдущего ответа и запрашивает строки после неё в заданном порядке. Внешне это может быть непрозрачный токен, а внутри — значения полей сортировки.
Offset отвечает на вопрос «сколько строк пропустить», а курсор — на вопрос «после какой записи продолжить».
Сноска. Курсор здесь не обязан быть курсором PostgreSQL в смысле серверного объекта для последовательного чтения результата. В HTTP API это обычно токен, который клиент передаёт в параметре cursor или в ссылке на следующую страницу.
Как работает offset-подход
Простейший запрос для PostgreSQL 16 выглядит так:
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 50 OFFSET 10000;Сортировка по двум полям здесь важна. Одного created_at недостаточно: у нескольких публикаций может быть одинаковое время создания. Поле id добавляет детерминированный порядок.
Плюсы offset-пагинации очевидны:
- клиенту легко запросить страницу 1, 2 или 200;
- номер страницы можно показать в интерфейсе и сохранить в URL;
- подход удобно подключать к существующему SQL-запросу;
- переход на произвольную страницу не требует последовательного обхода предыдущих страниц.
Цена появляется на больших смещениях. База должна определить порядок строк, добраться до позиции после OFFSET и только затем вернуть записи из LIMIT. Индекс способен ускорить сортировку и поиск, но не превращает пропуск ста тысяч строк в бесплатную операцию.
Когда offset всё ещё оправдан
Для административной таблицы на несколько тысяч строк простота часто важнее идеальной масштабируемости. Offset также подходит, если пользователю действительно нужны номера страниц, а набор данных почти не меняется во время просмотра.
Не обещайте клиенту точное значение total_pages, если параллельно идут вставки и удаления. Между запросом общего количества строк и загрузкой страницы данные могут измениться.
Как работает курсорная пагинация
Предположим, API показывает публикации от новых к старым. После первой страницы клиент получил последнюю запись с created_at = '2025-01-20 10:00:00+00' и id = 4812. Следующий запрос может использовать эти значения как границу:
SELECT id, created_at, title
FROM posts
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 50;Сравнение кортежей в PostgreSQL идёт слева направо: сначала сравнивается created_at, а при равенстве — id. Условие совпадает с направлением сортировки: для новых записей используется <, потому что следующая страница движется к более ранним значениям.
Для такого запроса нужен составной индекс:
CREATE INDEX posts_created_at_id_idx
ON posts (created_at DESC, id DESC);В боевой базе индекс создавайте с учётом принятой процедуры миграций и блокировок. Для крупных таблиц способ создания индекса может иметь отдельные требования — это уже вопрос конкретного окна обслуживания и нагрузки.
В HTTP API клиенту лучше не передавать два поля сортировки в открытом виде. Сервер может вернуть ответ примерно с такими полями:
{
"items": [
{"id": 4812, "created_at": "2025-01-20T10:00:00Z", "title": "..."}
],
"next_cursor": "YOUR_OPAQUE_CURSOR"
}Строка в примере — заглушка, а не готовый токен. Реализация должна решить, как кодировать и проверять курсор: например, хранить в нём значения сортировки и версию схемы. Не помещайте в него секреты. Если курсор подписывается, ключ должен находиться только на сервере.
Сравнение подходов
| Критерий | Offset | Курсор |
|---|---|---|
| Переход на произвольную страницу | Простой: достаточно номера страницы | Неудобен: нужно последовательно получить предыдущие курсоры |
| Глубокие страницы | Стоимость обычно растёт вместе со смещением | Запрос использует границу и индекс, без массового пропуска строк |
| Изменения данных во время просмотра | Вставки и удаления могут сдвигать страницы и создавать дубли | Граница по сортировке обычно даёт более стабильное продолжение |
| Сложность API | Низкая | Выше: нужно кодировать, проверять и иногда протухать курсор |
| Показ общего количества | Обычно сочетается с отдельным count-запросом | Не является обязательной частью модели и может быть дорогим |
Что ломается на практике
Нестабильная сортировка
Запросы вроде ORDER BY created_at DESC без уникального дополнительного поля опасны. Записи с одинаковым временем могут менять положение между запросами. Используйте пару «поле сортировки плюс уникальный ключ» и повторяйте ту же пару в условии курсора.
Неподходящий фильтр
Курсор действителен только в контексте тех же фильтров и сортировки. Если клиент получил курсор для status=published, а затем отправил его с status=draft, сервер должен либо отклонить запрос, либо безопасно обработать несовместимость. Практичный вариант — включать параметры фильтра и версию сортировки в подписываемую структуру курсора.
NULL в полях сортировки
Если created_at может быть NULL, порядок и условие продолжения нужно определить отдельно через NULLS FIRST или NULLS LAST. Проще использовать для пагинации поле, которое всегда заполнено.
Удаление последней записи
Если запись, на которой остановился клиент, удалили, это не обязательно ошибка. Следующий запрос с сохранёнными значениями границы всё ещё может вернуть корректную часть списка. Но результат зависит от того, какие поля использовались для сортировки и как устроен фильтр.
Изменение поля сортировки
Если публикация получила новое значение created_at между запросами, она может переместиться на уже просмотренную страницу или, наоборот, попасть в следующую. Курсор уменьшает число проблем, но не даёт снимок всей таблицы. Для строгого воспроизводимого экспорта нужен отдельный механизм фиксации набора данных.
Как выбрать подход
Выбирайте offset, если одновременно выполняются несколько условий:
- пользователю нужны номера страниц и переход к произвольной странице;
- глубокие страницы редки или размер набора ограничен;
- простота клиентского API важнее оптимизации длинного списка;
- вы готовы измерить стоимость запроса на реальном объёме данных.
Выбирайте курсор, если список листают последовательно, данные часто меняются, а глубина просмотра может быть большой. Это типичный вариант для лент, журналов событий, каталогов с бесконечной прокруткой и API, которые отдают данные мобильным клиентам.
Не сравнивайте варианты на пустой тестовой таблице. Для PostgreSQL 16 проверьте оба запроса через EXPLAIN (ANALYZE, BUFFERS) на данных, близких к боевым. Снимите время выполнения, число прочитанных блоков и план на нескольких смещениях: например, в начале, в середине и ближе к концу набора. В проде дополнительно смотрите p95 и p99 задержки, долю запросов к последним страницам и количество повторных запросов после ошибок курсора.
Цена миграции с offset на курсор
Переход затрагивает не только SQL. Клиент больше не может надёжно сформировать ссылку на страницу 200, а серверу нужно договориться о сроке жизни курсора, поведении при неверном токене и изменении схемы сортировки.
Совместимый API часто оставляет offset для старых клиентов и добавляет курсорный режим для новых. Важно не смешивать параметры в одном запросе без явного правила: например, определить, что при наличии cursor параметры page и offset запрещены.
Инвалидация курсоров — отдельная тема. Здесь важен только принцип: формат токена должен иметь версию, а сервер должен уметь вернуть понятную ошибку вместо SQL-исключения или бесконечного повторения одного запроса.
Что делать дальше
Начните с индекса и измерений, а не с переписывания API. Зафиксируйте порядок сортировки, добавьте уникальный ключ, проверьте план PostgreSQL на глубоком offset и на курсорном условии. Затем решите, нужен ли продукту переход к произвольной странице. Если нет, отдавайте предпочтение курсору и отдельно протестируйте вставки, удаления, повтор запроса и устаревший токен.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: PostgreSQL 16 Documentation: LIMIT and OFFSET, PostgreSQL 16 Documentation: Indexes and ORDER BY
Частые вопросы
Курсорная пагинация всегда быстрее offset?
Можно ли с курсором открыть страницу 100?
Почему в offset-пагинации появляются дубли?
Можно ли использовать курсор только с одним полем?
Что делать, если курсор перестал работать после релиза?
Нужно ли считать total для курсорной пагинации?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.