Keyset-пагінація (курсорна, «seek method») продовжує вибірку з місця, де закінчилась попередня сторінка, умовою за ключем сортування, а не пропуском N рядків:
-- перша сторінка
SELECT id, title, published_at FROM posts
ORDER BY published_at DESC, id DESC
LIMIT 20;
-- наступна: після останнього елемента (published_at = '2026-09-30 10:00', id = 1057)
SELECT id, title, published_at FROM posts
WHERE (published_at, id) < ('2026-09-30 10:00', 1057)
ORDER BY published_at DESC, id DESC
LIMIT 20;
Ключові деталі:
- унікальність порядку: сортування лише за
published_atнеоднозначне - у кількох постів однаковий час, і на межі сторінок записи загубляться чи задвояться. До ключа додають унікальну колонку (id) як «розв'язувач»; - індекс під сортування:
(published_at DESC, id DESC)- тоді кожна сторінка - короткий пошук в індексі незалежно від глибини; - порівняння кортежів
(a, b) < (x, y)підтримують PostgreSQL і MySQL, але оптимізатор MySQL не завжди використовує для нього індекс - інколи надійніше розгорнута умоваa < x OR (a = x AND b < y); - NULL у ключі сортування ламає порівняння - такі колонки або виключають, або обробляють окремо;
- курсор непрозорий для клієнта: значення кодують (base64 JSON) і, бажано, підписують, щоб клієнт не підставляв довільні значення. Laravel
cursorPaginate()робить це сам.
Чому total дорогий. COUNT(*) з тими самими фільтрами - окремий запит, що проходить усі відповідні рядки. На таблиці з мільйонами записів і складними фільтрами він може коштувати більше, ніж сама сторінка, - і виконується на кожну сторінку. У PostgreSQL через MVCC навіть COUNT(*) без умов читає всю таблицю чи індекс.
Що робити замість точного total:
- не показувати - лише «далі» (
has_more), як у стрічках і більшості великих API; - рахувати до межі:
SELECT COUNT(*) FROM (SELECT 1 ... LIMIT 10001)і показувати «10 000+»; - приблизна кількість зі статистики бази (
reltuplesу PostgreSQL,EXPLAIN) - для оцінок «близько 2,3 млн»; - кешувати підрахунок на хвилини, якщо точність до запису не потрібна.
Обмеження keyset: немає переходу на довільну сторінку і зручного «сторінка 5 з 120». Для адмінок це інколи прийнятний компроміс з simplePaginate, для API з великими обсягами - стандарт.
Докладніше в документації: No Offset: пагінація без зміщення