Каталог с фильтрами в Django: сравнение способов
Фильтр по цене, категории и брендам можно собрать на голых QuerySet, на django-filter или отдать в Elasticsearch. Разбираем цену каждого решения на живом каталоге с фасетами.

Каталог на пять тысяч товаров с фильтрами по категории, бренду, цене и трём характеристикам — типовая задача. Пока фильтров два, любой способ работает. Проблемы начинаются, когда нужны фасеты (счётчики «Nike — 42 товара»), комбинация галочек и сортировка, а страница каталога открывается по 800 мс из-за десятка запросов на подсчёт. Разберём три подхода на Django 5.x и PostgreSQL 16: ручные QuerySet, библиотеку django-filter и вынос поиска в Elasticsearch. У каждого своя цена в коде, в нагрузке на базу и в сроках.
Задача, на которой сравниваем
Модель товара упрощённо такая:
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=255)
category = models.ForeignKey('Category', on_delete=models.PROTECT)
brand = models.ForeignKey('Brand', on_delete=models.PROTECT)
price = models.DecimalField(max_digits=10, decimal_places=2)
in_stock = models.BooleanField(default=True)
class Meta:
indexes = [
models.Index(fields=['category', 'brand']),
models.Index(fields=['price']),
]
Фронтенд шлёт GET-параметры вида ?category=5&brand=3&brand=7&price_min=1000&price_max=5000&in_stock=1. Нужно вернуть отфильтрованный список, пагинацию и счётчики по каждому доступному значению фильтра. Именно фасеты — то место, где подходы расходятся сильнее всего.
Подход 1. Ручной QuerySet
Собираем фильтр в представлении, разбирая параметры вручную. Выигрыш — полный контроль и ноль зависимостей. Цена — код разрастается с каждым новым фильтром, и легко получить SQL-инъекцию поведения, если брать значения из запроса без проверки.
from decimal import Decimal, InvalidOperation
from django.shortcuts import render
from django.db.models import Q
from .models import Product
def catalog(request):
qs = Product.objects.select_related('category', 'brand')
category = request.GET.get('category')
if category and category.isdigit():
qs = qs.filter(category_id=int(category))
# несколько брендов -> OR внутри одного фильтра
brands = request.GET.getlist('brand')
brand_ids = [int(b) for b in brands if b.isdigit()]
if brand_ids:
qs = qs.filter(brand_id__in=brand_ids)
price_min = request.GET.get('price_min')
if price_min:
try:
qs = qs.filter(price__gte=Decimal(price_min))
except InvalidOperation:
pass # игнорируем мусор в параметре
price_max = request.GET.get('price_max')
if price_max:
try:
qs = qs.filter(price__lte=Decimal(price_max))
except InvalidOperation:
pass
if request.GET.get('in_stock') == '1':
qs = qs.filter(in_stock=True)
return render(request, 'catalog.html', {'products': qs})
Обратите внимание: getlist('brand') собирает все повторы параметра, а __in превращает их в один WHERE brand_id IN (...) — это OR внутри одного признака, что и нужно фасетному фильтру. Проверка isdigit() и try/except на Decimal — не украшение: без них любой ?price_min=abc уронит представление с InvalidOperation.
Фасеты в этом подходе считаются отдельными запросами. Наивная реализация — по одному COUNT на каждое значение — легко даёт двадцать запросов на страницу. Правильнее агрегировать одним проходом:
from django.db.models import Count
# счётчики по брендам среди уже отфильтрованного по остальным условиям набора
brand_facets = (
qs_without_brand_filter
.values('brand_id', 'brand__name')
.annotate(total=Count('id'))
.order_by('brand__name')
)
Тонкость фасетов: счётчик по бренду считается по набору, отфильтрованному всеми кроме самого бренда — иначе выбранный бренд обнулит счётчики соседних. Отсюда и qs_without_brand_filter. Реализовать это руками можно, но код быстро становится нечитаемым.
Когда хватает
Два-три фильтра, каталог до нескольких десятков тысяч строк, фасеты не нужны или примитивные. PostgreSQL с индексами по category, brand и price отдаёт такой запрос за единицы миллисекунд.
Подход 2. django-filter
Библиотека django-filter убирает ручной разбор параметров. Вы описываете FilterSet, она сама строит форму, валидирует значения и применяет фильтры к QuerySet. Работает и с обычными вьюхами, и с DRF.
# pip install django-filter (проверяйте актуальную версию в PyPI)
import django_filters
from .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')
brand = django_filters.ModelMultipleChoiceFilter(
field_name='brand',
queryset=Brand.objects.all(),
)
class Meta:
model = Product
fields = ['category', 'brand', 'in_stock']
def catalog(request):
base_qs = Product.objects.select_related('category', 'brand')
f = ProductFilter(request.GET, queryset=base_qs)
# f.qs — отфильтрованный QuerySet, f.form — готовая форма фильтров
return render(request, 'catalog.html', {'filter': f})
Выигрыш очевиден: валидация, обработка нескольких значений и построение формы — из коробки. ModelMultipleChoiceFilter сам делает __in по нескольким брендам. Цена — фасетные счётчики django-filter не считает. Это фильтрация, а не фасетный поиск. Счётчики вы всё равно пишете вручную поверх f.qs, как в первом подходе.
django-filter решает разбор и валидацию параметров, но не решает фасеты и не ускоряет базу. Если проблема была в скорости COUNT-ов, библиотека её не уберёт — она уберёт бойлерплейт.
Ещё нюанс: точное имя параметра lookup_expr и набор встроенных фильтров сверяйте с документацией вашей версии — API между мажорными релизами менялся, и код из старых туториалов может не завестись.
Подход 3. Elasticsearch (или OpenSearch)
Когда фильтров десять, среди них полнотекстовый поиск по названию, а фасеты нужны на каждой странице, реляционная база начинает проигрывать. Поисковый движок считает и результат, и все агрегации (фасеты) за один запрос — это его основная работа. В Elasticsearch фасеты — это aggregations, они возвращаются вместе с выборкой.
Схематично идея: вы индексируете товары в отдельное хранилище, при поиске отправляете туда запрос с фильтрами и агрегациями, а из PostgreSQL при необходимости дочитываете свежие поля по ID. Django-обвязку обычно делают через пакет django-elasticsearch-dsl — конкретные имена классов и версию совместимости с вашим кластером проверяйте в его документации, они зависят от версии клиента Elasticsearch.
Цена такого решения высокая и не только в коде:
- отдельный кластер, который надо разворачивать, мониторить и обновлять;
- синхронизация индекса с базой — при каждом изменении товара индекс нужно обновлять, и это отдельный источник багов (товар обновился, индекс отстал, пользователь видит старую цену);
- индекс отдаёт данные с задержкой — согласованность «в конечном счёте», а не мгновенная.
Брать движок ради каталога на пять тысяч товаров с тремя фильтрами — стрелять из пушки. Он оправдан, когда фасеты тяжёлые, поиск полнотекстовый, а нагрузка на страницу каталога стала узким местом базы.
Сравнение
| Критерий | Ручной QuerySet | django-filter | Elasticsearch |
|---|---|---|---|
| Зависимости | нет | один пакет | кластер + пакет |
| Валидация параметров | руками | из коробки | руками на входе |
| Фасетные счётчики | руками | руками поверх qs | нативно (aggregations) |
| Полнотекстовый поиск | слабый | слабый | сильный |
| Согласованность данных | мгновенная | мгновенная | с задержкой |
| Порог оправданности | 2-3 фильтра | много простых фильтров | тяжёлые фасеты + поиск |
Когда что выбирать
- Ручной QuerySet — прототип, узкая задача, каталог, который не будет расти по числу фильтров. Пишете один раз и забываете.
- django-filter — растущий каталог с большим числом простых фильтров, особенно если у вас DRF и фронтенд отдельно. Убирает бойлерплейт, но фасеты и скорость базы остаются на вас.
- Elasticsearch — фасеты на каждой странице, полнотекстовый поиск, замеренная нагрузка на PostgreSQL от COUNT-запросов. Не берите его на вырост, если проблемы ещё нет.
Инвалидация и прогрев поискового индекса — отдельная большая тема, здесь мы её не разворачиваем.
Что мерить и делать дальше
Прежде чем усложнять, замерьте. Включите django-debug-toolbar на странице каталога и посмотрите число SQL-запросов и время. Если каталог тормозит на подсчётах — сначала проверьте индексы через EXPLAIN ANALYZE в PostgreSQL и уберите N+1 через select_related/prefetch_related. В девяти случаях из десяти проблема не в отсутствии Elasticsearch, а в лишних запросах и недостающем индексе. Двигайтесь от простого: QuerySet → добавить django-filter, когда фильтров стало много → поисковый движок, только когда упёрлись в базу и это подтверждено замерами.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: django-filter — официальная документация, Django QuerySet API reference
Частые вопросы
У меня другая версия django-filter, код не работает
Как считать фасеты, чтобы не плодить десятки COUNT-запросов
Обязательно ли ставить Elasticsearch для интернет-магазина
Фильтры работают, но страница открывается медленно — с чего начать
Безопасно ли брать значения фильтров прямо из request.GET
Можно ли комбинировать django-filter и ручные фасеты
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.