Пошук уроків, статей та іншого контенту
Два підходи до розбиття великих списків на сторінки та компроміси кожного.
Повертати мільйон рядків одним запитом — погана ідея і для сервера (пам'ять, час обробки), і для клієнта (мережа, рендеринг). Пагінація ділить результат на менші, керовані частини, які клієнт запитує послідовно.
Класичний підхід — вказати, скільки рядків пропустити (OFFSET) і скільки взяти (LIMIT):
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 40; -- третя сторінка по 20Проста для розуміння й реалізації — клієнт просто вказує номер сторінки, сервер обчислює offset.
Два реальні недоліки: продуктивність падає з ростом offset (база даних усе одно має «пройти» через усі пропущені рядки), і результати можуть «зсуватись» — якщо між запитом сторінки 1 і сторінки 2 хтось додав новий рядок на початок списку, елемент може повторитися на двох сторінках або взагалі пропасти.
Замість номера сторінки клієнт передає «курсор» — зазвичай значення унікального, впорядкованого поля (id чи created_at) останнього елемента попередньої сторінки. Наступний запит просить елементи «після» цього курсора:
SELECT * FROM posts
WHERE created_at < '2026-06-15T10:00:00Z' -- курсор із попередньої сторінки
ORDER BY created_at DESC
LIMIT 20;Продуктивність не деградує з глибиною пагінації (база даних одразу переходить до потрібного місця через індекс, модуль «Індекси в PostgreSQL»), і додавання нових рядків не зсуває вже отримані сторінки.
Offset — простіше реалізувати, дозволяє «перейти на сторінку N» напряму, але деградує на великих offset і нестабільний при змінах даних між запитами.
Cursor — стабільний і швидкий на будь-якій глибині, але не дозволяє перейти одразу на довільну сторінку — лише «вперед» і «назад» від поточної позиції.
Offset підходить для невеликих, рідко змінюваних наборів даних і UI з номерами сторінок (адмін-панелі, каталоги). Cursor — стандартний вибір для нескінченного скролу, стрічок соціальних мереж і будь-якого API з великими або часто змінюваними наборами даних.
Використовувати як курсор поле без гарантії унікальності (наприклад, лише created_at) — якщо кілька рядків мають однакову мітку часу, частина результатів може повторюватись або губитись між сторінками; безпечніше комбінувати created_at з унікальним id як tie-breaker.
Дозволяти клієнту передавати довільний OFFSET без обмеження — на глибоких сторінках база даних усе одно перебирає всі пропущені рядки, тож без ліміту на максимальний offset один запит може навантажити базу непропорційно до розміру відповіді.
Плутати курсор із простим номером сторінки — курсор повинен бути непрозорим значенням, отриманим із попередньої відповіді сервера, а не значенням, яке клієнт обчислює сам (наприклад, підставляючи довільну дату).
Offset-пагінація проста в реалізації й дозволяє перехід на довільну сторінку, але деградує на глибоких офсетах і нестабільна при змінах даних між запитами. Cursor-пагінація стабільна й швидка на будь-якій глибині завдяки індексу на полі курсора, ціною втрати можливості перейти напряму на довільну сторінку. Для стрічок, що постійно оновлюються, і великих наборів даних cursor — практично стандартний вибір.