Каталог с фильтром на Django: разбор на живом примере
Пользователь выбирает три чекбокса, а страница отдаёт 400 SQL-запросов и грузится четыре секунды. Разбираем, как собрать фильтруемый каталог на Django без N+1 и с честной пагинацией.

Каталог из 40 000 товаров, слева фильтр по категории, бренду, цене и наличию. Пользователь ставит галочку «в наличии», прайс от 1000 до 5000, бренд Acme — и страница задумывается на несколько секунд, а в Django Debug Toolbar светятся сотни запросов. Разберём, как собрать такой фильтр правильно: не руками парсить request.GET, не плодить if на каждый параметр и не ловить N+1 на списке карточек. Всё на Django 5.x и django-filter 24.x.
Угол этого материала — именно фильтрация списка и её цена по запросам к БД. Сортировку, фасетные счётчики («сколько товаров под каждым фильтром») и полнотекстовый поиск не трогаем — это отдельные темы.
Что делает наивная реализация плохой
Первое, что пишут в такой ситуации — ветвление прямо во вьюхе:
def catalog(request):
qs = Product.objects.all()
if request.GET.get('brand'):
qs = qs.filter(brand_id=request.GET['brand'])
if request.GET.get('min_price'):
qs = qs.filter(price__gte=request.GET['min_price'])
# ... ещё пять таких if
return render(request, 'catalog.html', {'products': qs})
Проблем сразу три. Значения из request.GET не валидируются: min_price=abc уронит запрос исключением на уровне БД. При выводе карточек, где показываются product.brand.name и product.category.name, вы получаете N+1 — по два дополнительных запроса на каждый товар. И на 40 000 записей без LIMIT вы тянете весь набор в память шаблона.
Базовый фильтр на django-filter
django-filter снимает ручной разбор параметров и валидацию. Устанавливается отдельно от Django:
pip install django-filter
Добавьте 'django_filters' в INSTALLED_APPS. Дальше описываете фильтр декларативно:
import django_filters
from .models import Product
class ProductFilter(django_filters.FilterSet):
# диапазон цены: параметры price_min и price_max
price = django_filters.RangeFilter()
# текстовый поиск по названию, регистронезависимо
name = django_filters.CharFilter(lookup_expr='icontains')
class Meta:
model = Product
# brand и category превратятся в фильтры по точному совпадению
fields = ['brand', 'category', 'in_stock']
Вьюха на функции:
from django.core.paginator import Paginator
from django.shortcuts import render
from .filters import ProductFilter
from .models import Product
def catalog(request):
base_qs = (
Product.objects
.select_related('brand', 'category') # убираем N+1 на карточках
.order_by('id')
)
product_filter = ProductFilter(request.GET, queryset=base_qs)
paginator = Paginator(product_filter.qs, 24)
page = paginator.get_page(request.GET.get('page'))
return render(request, 'catalog.html', {
'filter': product_filter, # form для отрисовки полей фильтра
'page': page,
})
filter.qs — это ленивый QuerySet, до вызова пагинатора запрос к БД не уходит. Пагинатор добавит два запроса: COUNT(*) для числа страниц и сам SELECT ... LIMIT 24. Итого на страницу — три запроса вместо сотен. Это и есть цель.
Правило простое: количество SQL-запросов на список не должно зависеть от количества товаров на странице. Если растёт — вы забылиselect_relatedилиprefetch_related.
Когда связь «многие-ко-многим»
select_related работает только для ForeignKey и OneToOne — того, что подтягивается через JOIN. Если у товара есть теги (ManyToManyField) и вы их показываете в карточке, нужен prefetch_related('tags'). Он делает один дополнительный запрос на всю страницу, а не JOIN, потому что JOIN по M2M размножил бы строки.
Фильтр по нескольким значениям одного поля
Типичное требование: выбрать сразу несколько брендов чекбоксами. Обычный brand берёт одно значение. Нужен ModelMultipleChoiceFilter:
class ProductFilter(django_filters.FilterSet):
brand = django_filters.ModelMultipleChoiceFilter(
queryset=Brand.objects.all(),
# URL вида ?brand=1&brand=5 даст WHERE brand_id IN (1, 5)
)
class Meta:
model = Product
fields = ['brand', 'category', 'in_stock']
Под капотом это одно условие IN (...), а не отдельный запрос на бренд. Цена — тот же один SELECT.
Три подхода к сборке фильтра — и чем платите
Ручной разбор, django-filter и фильтр-форма на django.forms — три реальных варианта. Разница не в том, «что модно», а в том, где всплывёт цена.
| Подход | Что выигрываете | Чем платите |
|---|---|---|
Ручные if в вьюхе | Ноль зависимостей, всё видно | Валидацию пишете сами, легко словить N+1 и SQL-исключение на кривом вводе |
django-filter | Декларативность, валидация из коробки, готовая форма | Внешняя зависимость; сложную бизнес-логику фильтра всё равно писать вручную через method= |
Форма на django.forms + ручной filter() | Полный контроль над валидацией и виджетами | Больше кода на ровном месте, повторяете то, что django-filter уже умеет |
Когда что выбирать
- Один-два параметра, простой проект — ручной разбор с явной валидацией через
int()вtry/except. Тянуть библиотеку ради двухifнезачем. - Каталог с 5+ фильтрами, диапазонами и множественным выбором —
django-filter. Ровно тот случай, ради которого он и написан. - Фильтр — часть сложной формы с зависимыми полями —
django.forms, где вы всё равно контролируете рендер иclean_*.
Сложное условие: диапазон дат или расчётное поле
Когда фильтр не сводится к одному полю модели, используйте method. Например, «товары со скидкой больше 20%», где скидка не хранится, а считается:
from django.db.models import F
class ProductFilter(django_filters.FilterSet):
min_discount = django_filters.NumberFilter(method='filter_discount')
def filter_discount(self, queryset, name, value):
# value — процент, введённый пользователем
return queryset.filter(
old_price__gt=F('price'),
price__lte=F('old_price') * (1 - value / 100),
)
class Meta:
model = Product
fields = ['brand']
Вся арифметика уходит на сторону БД через F(), в Python значения не тянутся. Это важно: если посчитать скидку в цикле по QuerySet, вы снова материализуете весь набор.
Что ломается на практике
- N+1 возвращается через шаблон. Вы поставили
select_related, но в карточке обратились кproduct.tags.all— и на каждый товар полетел запрос. Проверяйте Debug Toolbar именно на отрендеренной странице, а не на голом QuerySet. - Пустой фильтр отдаёт не то. Если
request.GETпустой,filter.qsвернёт весьbase_qs. Это нормально, но убедитесь, чтоbase_qsуже отфильтрован по «опубликовано» и подобному — фильтр не должен показывать черновики. - Пагинация теряет параметры. Ссылка «страница 2» без остальных GET-параметров сбросит фильтр. В шаблоне собирайте querystring вручную или используйте тег, сохраняющий текущие параметры.
- Диапазон цены на строках.
RangeFilterждёт числа. Если полеprice—CharField(так иногда делают под валюту), сравнение пойдёт лексикографически и «100» окажется больше «20». Храните цену вDecimalField.
Что делать дальше
Когда список фильтруется тремя запросами и без N+1, следующий шаг — измерить реальное время под нагрузкой. Включите django-debug-toolbar в dev и посмотрите вкладку SQL на самой тяжёлой комбинации фильтров. Если тормозит один конкретный WHERE ... IN или диапазон по цене — смотрите план через QuerySet.explain() и добавляйте индекс на поля, по которым фильтруете чаще всего. Фасетные счётчики (число товаров под каждым чекбоксом) и сортировку выносите отдельной задачей — они меняют структуру запроса и заслуживают своего разбора.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: django-filter — официальная документация, Django QuerySet API: select_related и prefetch_related
Частые вопросы
У меня другая версия django-filter, синтаксис отличается?
Фильтр не применяется, страница показывает все товары. Почему?
select_related или prefetch_related — как выбрать?
Нужен ли django-filter вообще, или хватит ручных фильтров?
Пагинация сбрасывает выбранные фильтры при переходе на вторую страницу. Как исправить?
Как понять, что на странице появился N+1, если визуально всё работает?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.