Пагинация, фильтрация и сортировка в DRF: где ломается
Список из 200 тысяч записей отдаёт 502, а фильтр по внешнему ключу генерирует лишний запрос на каждую строку. Разбираем типичные ошибки пагинации, фильтрации и сортировки в Django REST Framework.

Эндпоинт /api/orders/ отдавал 200 миллисекунд на тестовой базе в тысячу строк. В проде на 400 тысячах заказов тот же запрос упирался в таймаут gateway и возвращал 502. Причина не в железе: PageNumberPagination с фильтром без индекса и сортировкой по вычисляемому полю делает full scan таблицы на каждый запрос. Разбираем, как пагинация, фильтрация и сортировка в DRF ломаются под реальными данными и что с этим делать. Актуально для Django REST Framework 3.14–3.15 и Django 4.2/5.x.
Речь только о серверной стороне: как отдавать список так, чтобы он не клал базу. Кеширование ответов и права доступа к объектам — отдельные темы, здесь их не трогаем.
Ошибка первая: PageNumberPagination на больших таблицах
По умолчанию DRF предлагает PageNumberPagination. Клиент запрашивает ?page=3&page_size=50, DRF транслирует это в SQL с LIMIT 50 OFFSET 100. Проблема в OFFSET: чтобы вернуть страницу 5000, база должна прочитать и отбросить 250 000 строк. С ростом номера страницы время растёт линейно.
Второй скрытый расход — подсчёт общего числа записей. Ответ PageNumberPagination содержит поле count, а значит на каждый запрос страницы выполняется отдельный SELECT COUNT(*) по всему отфильтрованному набору. На таблице в сотни тысяч строк этот COUNT сам по себе может занимать сотни миллисекунд.
OFFSET-пагинация удобна ровно до того момента, пока пользователи не начинают листать глубоко или пока таблица не выросла. Оба условия наступают в проде почти всегда.
Курсорная пагинация вместо смещения
Для больших и часто дополняемых наборов DRF предлагает CursorPagination. Вместо номера страницы клиент получает непрозрачный курсор, а SQL использует условие WHERE ordering_field < last_value вместо OFFSET. Время выборки не зависит от глубины листания, а COUNT не выполняется вовсе.
from rest_framework.pagination import CursorPagination
class OrderCursorPagination(CursorPagination):
page_size = 50
max_page_size = 200
# Поле сортировки обязано быть проиндексированным и не изменяться у записи.
# Минус означает сортировку по убыванию (сначала новые).
ordering = "-created_at"
Плата за курсор: клиент не может прыгнуть на произвольную страницу и не видит общего количества записей. Если фронту нужен номер «страница 47 из 900», курсор не подходит. Для бесконечной ленты и API «дай следующие 50» — подходит идеально.
Требование к полю сортировки
Поле в ordering должно быть стабильным и уникальным или почти уникальным. Если сортировать по полю с повторами (например, по статусу), записи с одинаковым значением на границе страницы могут потеряться или задвоиться. Добавляйте вторичное поле-уникум: ordering = ("-created_at", "-id").
Ошибка вторая: фильтрация без индекса и N+1 на связях
Подключить django-filter просто, и это создаёт ложное чувство готовности. Фильтр по полю без индекса в базе означает full scan при каждом запросе — DRF об этом не предупредит, потому что на маленькой базе всё быстро.
import django_filters
from rest_framework import viewsets
from django_filters.rest_framework import DjangoFilterBackend
from .models import Order
from .serializers import OrderSerializer
class OrderFilter(django_filters.FilterSet):
# Диапазон дат вместо точного совпадения
created_after = django_filters.DateTimeFilter(
field_name="created_at", lookup_expr="gte"
)
created_before = django_filters.DateTimeFilter(
field_name="created_at", lookup_expr="lte"
)
class Meta:
model = Order
fields = ["status", "customer"]
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderSerializer
filter_backends = [DjangoFilterBackend]
filterset_class = OrderFilter
def get_queryset(self):
# select_related убирает отдельный запрос на каждого customer,
# если сериализатор обращается к полям связанной модели.
return Order.objects.select_related("customer").all()
Два момента в этом коде решают большинство проблем:
- Индекс под фильтр. Если фильтруете по
statusиcreated_at— добавьте на них индексы в модели черезMeta.indexesилиdb_index=True. Составной фильтр (статус плюс диапазон дат) обслуживается составным индексом, порядок полей в котором совпадает с порядком в запросе. - select_related / prefetch_related. Сериализатор, который читает
order.customer.name, безselect_relatedделает по одному запросу на каждый заказ в странице. Пятьдесят заказов — пятьдесят лишних запросов. Для связей «многие ко многим» и обратных ForeignKey используйтеprefetch_related.
Как поймать лишние запросы
Не гадайте — измеряйте. Django Debug Toolbar показывает число SQL-запросов на страницу. В тестах пригодится assertNumQueries: он падает, если запросов стало больше ожидаемого, и ловит регрессию N+1 до прода.
from django.test import TestCase
from django.urls import reverse
class OrderListQueryCountTest(TestCase):
def test_list_does_not_grow_with_rows(self):
# Число запросов не должно зависеть от количества заказов на странице.
# Если тест падает при росте фикстур — где-то потерян select_related.
with self.assertNumQueries(3):
self.client.get(reverse("order-list"))
Ошибка третья: сортировка по любому полю, которое прислал клиент
Подключив OrderingFilter без ограничений, вы разрешаете клиенту сортировать по любому полю модели. Это две беды сразу. Первая — сортировка по неиндексированному или вычисляемому полю кладёт базу. Вторая — потенциальная утечка: через ordering можно косвенно перебирать значения полей, к которым у клиента не должно быть доступа.
from rest_framework.filters import OrderingFilter
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderSerializer
filter_backends = [DjangoFilterBackend, OrderingFilter]
filterset_class = OrderFilter
# Белый список: только эти поля можно указывать в ?ordering=
ordering_fields = ["created_at", "total_amount"]
# Значение по умолчанию, если клиент ничего не прислал
ordering = ["-created_at"]
Всегда задавайте ordering_fields явным списком, а не значением '__all__'. И всегда указывайте ordering по умолчанию — без явного порядка на границах страниц пагинация ведёт себя недетерминированно, потому что база не гарантирует стабильный порядок без ORDER BY.
Когда что выбирать
| Класс пагинации | Когда брать | Чем платите |
|---|---|---|
| PageNumberPagination | Небольшие или редко растущие наборы, нужны номера страниц | OFFSET и COUNT деградируют с ростом таблицы и глубины |
| LimitOffsetPagination | Клиенту нужен произвольный сдвиг, набор небольшой | Та же проблема OFFSET, что и у PageNumber |
| CursorPagination | Большие, часто дополняемые наборы, лента, стабильная сортировка | Нет номеров страниц и общего count, нельзя прыгнуть в середину |
Практический критерий: если таблица растёт и пользователи листают глубже нескольких страниц — курсор. Если это справочник на пару тысяч строк и фронту нужен пагинатор с номерами — PageNumber.
Что ломается в проде
- COUNT на каждый запрос. На больших наборах отдельный
SELECT COUNT(*)ест время. Курсорная пагинация его не делает; если нужен номерной пагинатор — рассмотрите приблизительный подсчёт или кеш числа записей. - Смена сортировки на лету. Если поле
orderingу курсора меняется у существующих записей (например, сортировка по обновляемомуupdated_at), клиент при листании увидит пропуски и дубли. Курсор безопасен только по неизменяемым полям. - Отсутствие индекса под составной фильтр. Фильтр по
statusплюс диапазон дат без составного индекса — full scan. План запроса смотрите черезEXPLAIN ANALYZEна реальном объёме, а не на пустой базе. - N+1 в сериализаторе. Появляется незаметно: добавили вложенное поле в сериализатор — и число запросов на страницу выросло. Ловится через
assertNumQueries.
Что сделать дальше
Порядок действий на живом эндпоинте списка:
- Снимите план тяжёлого запроса через
EXPLAIN ANALYZEна копии прод-данных. Full scan по фильтруемому полю — первый кандидат на индекс. - Прогоните
assertNumQueriesна списочном эндпоинте с несколькими объектами в фикстурах. Рост числа запросов при добавлении строк — это N+1, лечитсяselect_related/prefetch_related. - Если таблица большая и растёт — переведите эндпоинт на
CursorPaginationс индексированным неизменяемым полем сортировки. - Замкните
ordering_fieldsбелым списком проиндексированных полей и задайтеorderingпо умолчанию.
В проде смотрите на p95 времени ответа списочных эндпоинтов и на число медленных запросов в логе базы, а не на среднее по тестовой базе — оно скрывает деградацию на объёме.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: Django REST Framework — Pagination, Django REST Framework — Filtering
Частые вопросы
У меня Django REST Framework 3.12, а не 3.14 — код сработает?
Что делать, если фронту нужны и номера страниц, и производительность на большой таблице?
Как понять, что тормозит — фильтр, сортировка или сериализатор?
Курсорная пагинация не отдаёт общее число записей — это критично?
Нужен ли индекс, если фильтрую по полю с db_index=True в модели?
select_related не помог, запросов всё равно много — почему?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.