django-filter перед продакшеном: чек-лист без лишних запросов
django-filter удобен на прототипе и коварен под нагрузкой: незаметный N+1, фильтр по всем полям и параметры, которые ловят SQL-инъекцию через ORM. Разбираем, что проверить до релиза.

Список товаров с фильтрами по категории, цене и бренду. На проекте с 200 тысячами записей страница отдаётся за 3 секунды, в логах на один HTTP-запрос — 40+ SQL-запросов, а FilterSet с fields = '__all__' позволяет фильтровать по полю, которое вы не собирались отдавать наружу. Всё это — типичные грабли django-filter, и почти всё ловится до продакшена глазами по чек-листу. Материал для django-filter 24.x на Django 4.2/5.x и DRF, если у вас связка с API.
Речь идёт именно о ревью и предпродовой проверке готового FilterSet. Как писать первый фильтр — отдельная тема; здесь предполагается, что фильтры уже есть и надо понять, что с ними не так под реальными данными.
Пункт 1. Никакого fields = '__all__'
Самая частая находка на ревью. Meta.fields = '__all__' генерирует фильтры по каждому полю модели, включая те, что вы не планировали открывать: внутренние флаги, is_staff, служебные внешние ключи. Клиент подбирает имя параметра и фильтрует по нему.
Проблема не в том, что данные утекают напрямую — сериализатор их может не отдать. Проблема в том, что фильтр по неиндексированному служебному полю превращается в seq scan, а перебор значений даёт оракул: по факту наличия результата видно, существует ли запись с таким флагом.
Явно перечисленный список полей — это не занудство ревьюера, а граница между тем, что вы контролируете, и тем, что контролирует клиент через query string.
Правильно — перечислять поля списком и держать этот список в PR на глазах:
import django_filters
from shop.models import Product
class ProductFilter(django_filters.FilterSet):
price_min = django_filters.NumberFilter(field_name="price", lookup_expr="gte")
price_max = django_filters.NumberFilter(field_name="price", lookup_expr="lte")
class Meta:
model = Product
# только то, что осознанно открыто наружу
fields = ["category", "brand", "in_stock"]
Пункт 2. Проверьте, что фильтр вообще упирается в индекс
django-filter превращает параметр в queryset.filter(...), а дальше всё решает база. Фильтр по полю без индекса на большой таблице — это последовательное сканирование на каждый запрос.
Как проверить: возьмите запрос, который порождает фильтр, и прогоните через EXPLAIN ANALYZE. Увидели Seq Scan на таблице в сотни тысяч строк — нужен индекс. Для фильтра по нескольким полям сразу пригодится составной индекс в том же порядке, в каком поля идут в WHERE.
Как посмотреть сам SQL, который уходит в базу:
# в shell_plus или обычном manage.py shell
from django.db import connection
from shop.filters import ProductFilter
qs = ProductFilter({"category": 3, "price_min": "1000"}).qs
print(qs.query) # готовый SQL с WHERE
# затем этот SQL прогнать через EXPLAIN ANALYZE в psql
Отдельный подводный камень — lookup_expr="icontains" для поиска по подстроке. Обычный btree-индекс на нём не работает: под капотом LIKE '%текст%' с ведущим процентом. На PostgreSQL 16 под это заводят GIN-индекс с pg_trgm, иначе поиск по названию на большой таблице ложит базу.
Пункт 3. Фильтр по связям — источник N+1
Сам django-filter N+1 не создаёт: filter() добавляет JOIN. N+1 появляется дальше, когда во view или сериализаторе вы обращаетесь к связанным объектам, а select_related/prefetch_related не проставлены.
Типичная связка: FilterSet фильтрует товары по бренду, а сериализатор для каждого товара читает product.brand.name. На 50 товарах в выдаче — 50 лишних запросов к таблице брендов.
Лечится на уровне базового queryset, а не фильтра:
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.generics import ListAPIView
from shop.models import Product
from shop.filters import ProductFilter
class ProductListView(ListAPIView):
filter_backends = [DjangoFilterBackend]
filterset_class = ProductFilter
# связи подтягиваем заранее, чтобы фильтр не породил N+1 в сериализаторе
queryset = (
Product.objects
.select_related("brand", "category")
.prefetch_related("images")
)
Проверка на ревью: включите django-debug-toolbar на стейджинге или залогируйте количество запросов через CaptureQueriesContext в тесте. Число SQL-запросов на страницу не должно расти с размером выдачи.
Пункт 4. ChoiceFilter и валидация вместо тихого молчания
По умолчанию невалидное значение параметра django-filter чаще всего просто игнорирует — фильтр не применяется, клиент получает полную выдачу и не понимает, почему фильтр «не работает». Для API это плохо: лучше явная ошибка 400, чем молчаливое возвращение всего каталога.
Для полей с фиксированным набором значений используйте ChoiceFilter с явными choices, а на уровне DRF включите строгую валидацию, чтобы кривой параметр возвращал ошибку, а не игнорировался. Поведение по обработке невалидных значений менялось и настраивается — сверьтесь с актуальной документацией django-filter вашей версии, не полагайтесь на память.
Пункт 5. OrderingFilter — не открывайте сортировку по всему
Сортировка через параметр — второй канал утечки после __all__. Если разрешить сортировку по произвольному полю, клиент отсортирует по неиндексированному столбцу и получит Sort над полным набором строк. Ограничивайте список полей, по которым разрешена сортировка, и убедитесь, что под них есть индексы.
Итоговый чек-лист перед релизом
- В
Meta.fieldsнет'__all__'— только явный список полей. - Каждое фильтруемое поле проверено через
EXPLAIN ANALYZEна боевом объёме данных, нетSeq Scanна больших таблицах. - Поля с
icontainsимеют trigram-индекс (или поиск вынесен в отдельный движок). - Базовый queryset во view содержит
select_related/prefetch_relatedпод все связи, которые читаются дальше. - Число SQL-запросов на страницу не зависит от размера выдачи — проверено на стейджинге.
- Невалидные значения параметров дают 400, а не молчаливую полную выдачу.
- Сортировка ограничена белым списком полей с индексами.
- Есть тест, который фиксирует и число запросов, и итоговый SQL хотя бы для основных комбинаций фильтров.
Как сравнить два подхода к тяжёлым фильтрам
Когда фильтр упирается в производительность, обычно выбирают между тюнингом ORM-запроса и выносом поиска в отдельную инфраструктуру.
| Критерий | ORM + индексы | Внешний поисковый движок |
|---|---|---|
| Стоимость внедрения | Низкая: индексы и select_related | Высокая: отдельный сервис, синхронизация данных |
| Поиск по подстроке | Терпимо через pg_trgm | Сильная сторона |
| Актуальность данных | Мгновенная | Задержка на индексацию |
| Когда выбирать | Точные фильтры по индексируемым полям | Полнотекстовый поиск, фасеты по миллионам записей |
Для большинства каталогов до нескольких сотен тысяч записей связка django-filter плюс грамотные индексы закрывает вопрос. Внешний движок оправдан, когда основная нагрузка — полнотекстовый поиск, а не фильтрация по категориям.
Что делать дальше
Заведите на стейджинге объём данных, близкий к боевому, и прогоните по нему основные комбинации фильтров с включённым логированием запросов. Настройте в продакшене логирование медленных SQL-запросов (в PostgreSQL — через log_min_duration_statement) и смотрите, какие фильтры реально доходят до Seq Scan. Показатель, за которым стоит следить: время ответа списочных эндпоинтов на p95 и число SQL-запросов на страницу.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: django-filter documentation, PostgreSQL 16: pg_trgm
Частые вопросы
У меня другая версия django-filter, чек-лист подойдёт?
Проверил всё, а страница всё равно медленная. Где искать?
Обязательно ли перечислять поля вручную вместо '__all__'?
Как поймать N+1 до продакшена, а не по жалобам?
Нужен ли отдельный индекс под фильтр по нескольким полям сразу?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.