Пагинация в Django: как делать правильно
OFFSET/LIMIT на большой таблице заставляет базу перебирать всё до нужной страницы. Разбираем, где ломается стандартный Paginator и когда переходить на пагинацию по курсору.

Стандартный Paginator из Django работает через LIMIT и OFFSET. На первых страницах это незаметно, но на странице 5000 база вынуждена отсчитать и отбросить все предыдущие строки, прежде чем вернуть двадцать нужных. Плюс отдельный COUNT(*) на каждый запрос страницы. На таблице в несколько миллионов строк это превращается в секунды ответа там, где раньше были миллисекунды. Разбираем три подхода, их цену и критерий выбора. Версии — Django 4.2 LTS и 5.x, поведение Paginator в этих ветках одинаковое.
Почему OFFSET дорожает с ростом номера страницы
Когда вы пишете queryset[100000:100020], Django генерирует SQL вида ... LIMIT 20 OFFSET 100000. PostgreSQL и MySQL не умеют «прыгнуть» к строке 100000 без её вычисления — они сканируют результат от начала, отбрасывают 100000 строк и только потом отдают 20. Стоимость запроса растёт линейно с номером страницы.
Второй источник затрат — count(). Django-Paginator вызывает COUNT(*) по всему querysetʼу, чтобы посчитать число страниц. На большой таблице с фильтрами и джойнами этот COUNT сам по себе может занимать сотни миллисекунд.
Пагинация по OFFSET удобна, пока пользователь не ушёл дальше первых десятков страниц. Проблема в том, что боты и парсеры уходят именно туда — и кладут базу.
Подход 1. Стандартный Paginator
Базовый инструмент Django. Подходит, когда данных немного или нужна навигация по номерам страниц с показом «страница 3 из 47».
from django.core.paginator import Paginator
def order_list(request):
orders = Order.objects.order_by('-created_at')
paginator = Paginator(orders, 20) # 20 записей на страницу
page_number = request.GET.get('page', 1)
page = paginator.get_page(page_number) # безопасно к некорректным номерам
return render(request, 'orders.html', {'page': page})
get_page() лучше page(): он не бросает исключение на «page=abc» или на номере за пределами диапазона, а возвращает первую или последнюю страницу. За удобство навигации платите двумя запросами на страницу (COUNT + выборка) и деградацией на больших OFFSET.
Как сбить стоимость COUNT, не меняя подход
Если точное число страниц не критично, можно переопределить count у paginatorʼа. Django позволяет подсунуть свой объект: например, при известном верхнем пределе или при использовании приблизительной оценки строк из системных таблиц PostgreSQL. Это отдельная тема — здесь фиксируем лишь, что COUNT(*) часто и есть главный тормоз, а не сама выборка.
Подход 2. Пагинация по курсору (keyset)
Вместо «пропусти N строк» мы говорим базе «дай строки после вот этого значения». Если есть индекс по полю сортировки, база сразу спускается в нужную точку дерева — стоимость не зависит от глубины.
# Первая страница: последние 20 заказов
orders = Order.objects.order_by('-id')[:20]
# Следующая страница: передаём id последнего заказа с предыдущей
last_id = request.GET.get('after') # id последней строки прошлой страницы
qs = Order.objects.order_by('-id')
if last_id:
qs = qs.filter(id__lt=last_id)
orders = qs[:20]
Здесь нет OFFSET и нет COUNT. Запрос страницы 5000 стоит столько же, сколько первой. Плата — вы теряете переход на произвольную страницу по номеру: только «вперёд» и «назад» относительно текущей позиции.
Курсор по нескольким полям
Сортировка по одному уникальному полю (id) — простой случай. Если сортируете по неуникальному полю, например по дате создания, при равных значениях строки могут перескакивать или дублироваться между страницами. Решение — составной ключ: сортируйте по (created_at, id) и сравнивайте пару.
from django.db.models import Q
# Курсор — пара (created_at, id) последней строки прошлой страницы
last_created = request.GET.get('after_created')
last_id = request.GET.get('after_id')
qs = Order.objects.order_by('-created_at', '-id')
if last_created and last_id:
# строки "меньше" курсора при сортировке по убыванию
qs = qs.filter(
Q(created_at__lt=last_created)
| Q(created_at=last_created, id__lt=last_id)
)
orders = qs[:20]
Индекс тогда нужен составной: по (created_at, id) в том же порядке, что и сортировка. Без него база всё равно уйдёт в полное сканирование, и весь смысл keyset пропадёт.
Подход 3. DRF CursorPagination для API
Если вы отдаёте данные через Django REST Framework, курсорную пагинацию не нужно писать руками. В DRF есть готовый класс CursorPagination, который кодирует курсор в непрозрачную строку и сам собирает ссылки next и previous.
from rest_framework.pagination import CursorPagination
class OrderCursorPagination(CursorPagination):
page_size = 20
ordering = '-created_at' # поле должно быть индексировано
# во вьюхе или настройках DRF
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Order.objects.all()
serializer_class = OrderSerializer
pagination_class = OrderCursorPagination
Клиент не видит номеров страниц — только курсор в URL следующей страницы. Для публичного API это чаще плюс: пользователь не может запросить page=999999 и уронить базу. Точные имена и поведение класса сверяйте с документацией вашей версии DRF — API пагинации там стабилен, но детали настроек между мажорными версиями уточнять стоит.
Когда что выбирать
| Критерий | Paginator (OFFSET) | Keyset / CursorPagination |
|---|---|---|
| Переход на произвольную страницу по номеру | Есть | Нет, только вперёд/назад |
| Показ «страница 3 из 47» | Есть | Нет (число страниц неизвестно) |
| Стоимость глубоких страниц | Растёт с номером | Постоянная |
| Нужен COUNT(*) | Да | Нет |
| Данные меняются во время листания | Строки могут дублироваться/пропадать | Устойчивее при сортировке по стабильному ключу |
| Типичное применение | Админка, каталоги с сотнями страниц | Ленты, публичные API, бесконечная прокрутка |
Практический критерий простой. Пользователю нужны кликабельные номера страниц и он не уйдёт глубже пары сотен — берите Paginator. Данных миллионы, есть бесконечная прокрутка или публичный API — берите keyset или CursorPagination.
Что ломается
- Дубли и пропуски при OFFSET на живых данных. Пока пользователь листает, кто-то вставил строку в начало отсортированного набора — все последующие страницы сдвигаются, и одна запись попадает на две страницы. Keyset этим не страдает, если ключ курсора неизменяемый.
- Keyset без индекса. Фильтр
id__lt=...без индекса по полю сортировки даёт полное сканирование. ПроверяйтеEXPLAIN ANALYZE: должен быть Index Scan, а не Seq Scan. - Курсор по неуникальному полю в одиночку. Сортировка только по
created_atпри одинаковых значениях времени теряет или повторяет строки. Всегда добавляйте уникальное поле-разрыватель, обычноid. - Тяжёлый COUNT в шаблоне. Вызов
paginator.countилиpaginator.num_pagesв цикле шаблона запускаетCOUNTзаново, если результат не закеширован в переменную.
Что делать дальше
Снимите EXPLAIN ANALYZE с реального запроса самой глубокой страницы, которую открывают пользователи, — обычно её видно в логах медленных запросов. Если там OFFSET с большим числом и сортировка идёт по индексированному полю, посчитайте, во сколько раз проседает ответ на глубине, и оцените переход на keyset. Для API-эндпоинтов, которые парсят боты, курсорную пагинацию имеет смысл ставить сразу, не дожидаясь деградации. Индексы под сортировку проверьте до, а не после релиза.
Помощник по курсовой и диплому: Большая работа поэтапно: от плана до защиты
Спросить за 25 ₽Источники: Django documentation — Pagination, Django REST Framework — Pagination (CursorPagination)
Частые вопросы
У меня Django 3.2, а не 4.2 — код подойдёт?
Keyset-пагинация быстрее — почему её не используют везде?
Как ускорить COUNT, если Paginator всё же нужен?
Что если после перехода на keyset строки всё равно дублируются?
Проверил в проде — глубокая страница всё равно медленная. Что смотреть?
Курсор в URL — это безопасно, если данные приватные?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.