Фильтры django-filter: сравниваем варианты и выбираем
Как задать фильтрацию списка в Django REST Framework: чистый FilterSet, method-фильтры, аннотации и raw-запросы. Что выиграете и чем заплатите в каждом случае.

У вас список заказов на 200 тысяч строк, и фронт просит отдавать его с фильтрами: по статусу, диапазону дат, минимальной сумме и поиску по имени клиента. Первый порыв — накидать filterset_fields списком строк и забыть. Работает, пока фильтры простые и совпадают с полями модели. Как только появляется фильтр «заказы за последние N дней» или «сумма позиций больше X», декларативный список перестаёт помогать, и начинается выбор между несколькими способами. Разберём их с ценой каждого.
Материал для django-filter 24.x в связке с Django REST Framework 3.15 и Django 5.x. Инвалидация кеша, пагинация и права доступа — отдельные темы, здесь только фильтрация.
Задача и почему список полей быстро упирается в потолок
Базовый способ подключить фильтрацию во вьюсете выглядит так:
from django_filters.rest_framework import DjangoFilterBackend
class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.all()
serializer_class = OrderSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ["status", "customer"]
Это даёт точное совпадение: ?status=paid&customer=17. Проблема в том, что реальные запросы редко про точное равенство. Нужен диапазон дат, нужна фильтрация «больше или равно», нужен фильтр, который не соответствует ни одному полю модели напрямую. filterset_fields умеет только сопоставлять имя поля с лукапом, и как только логика уходит в сторону, приходится писать FilterSet руками.
Четыре подхода
1. Декларативный FilterSet
Отдельный класс, где каждый фильтр — это поле. Читается как форма, поддерживает лукапы через field_name и lookup_expr.
import django_filters
from .models import Order
class OrderFilter(django_filters.FilterSet):
# Диапазон дат: ?created_after=2024-01-01&created_before=2024-02-01
created_after = django_filters.DateFilter(
field_name="created_at", lookup_expr="gte"
)
created_before = django_filters.DateFilter(
field_name="created_at", lookup_expr="lte"
)
min_total = django_filters.NumberFilter(
field_name="total", lookup_expr="gte"
)
class Meta:
model = Order
fields = ["status", "customer"]
Во вьюсете вместо filterset_fields указываете filterset_class = OrderFilter. Цена: за каждый нетривиальный фильтр — отдельное поле, но всё остаётся декларативным и предсказуемым. Django-filter сам собирает форму, валидирует ввод и не пускает мусор в ORM.
2. Method-фильтр для логики, которой нет в модели
Когда фильтр нельзя выразить одним лукапом — например, «заказы за последние N дней» или «есть хотя бы одна позиция с возвратом» — используется method. Фильтр отдаёт управление вашей функции, которая сама решает, что сделать с queryset.
from django.utils import timezone
from datetime import timedelta
class OrderFilter(django_filters.FilterSet):
last_days = django_filters.NumberFilter(method="filter_last_days")
def filter_last_days(self, queryset, name, value):
# value уже прошёл валидацию NumberFilter
threshold = timezone.now() - timedelta(days=int(value))
return queryset.filter(created_at__gte=threshold)
class Meta:
model = Order
fields = ["status"]
Плюс: любая логика, полный контроль над queryset. Минус: валидацию значения делаете вы. NumberFilter проверит, что пришло число, но диапазон и бизнес-правила («N не больше 365») — на вас. И method-фильтр легко превращается в место, где кто-то незаметно подклеивает N+1 или тяжёлый подзапрос.
3. Фильтр по аннотации
Когда фильтруете по вычисляемому значению — сумме позиций, количеству, флагу — заводите аннотацию в queryset вьюсета, а в фильтре ссылаетесь на неё по имени, как на обычное поле.
from django.db.models import Sum
class OrderViewSet(viewsets.ModelViewSet):
serializer_class = OrderSerializer
filter_backends = [DjangoFilterBackend]
filterset_class = OrderFilter
def get_queryset(self):
return Order.objects.annotate(
items_total=Sum("items__price")
)
class OrderFilter(django_filters.FilterSet):
# items_total появляется из annotate выше
items_total = django_filters.NumberFilter(lookup_expr="gte")
class Meta:
model = Order
fields = ["status"]
Так фильтрация по вычисляемому полю превращается в HAVING на стороне БД, а не в фильтрацию в Python. Цена — аннотация выполняется всегда, даже когда фильтр по ней не пришёл. Если аннотация тяжёлая, а нужна редко, вешайте её условно внутри get_queryset.
4. Raw SQL или RawSQL-выражение
Крайний случай: фильтр, который ORM не выражает разумно, — оконная функция, специфичный оператор PostgreSQL, полнотекстовый поиск с ранжированием. Тогда фильтр вычисляет условие и добавляет его через .extra() (устаревает) или, что правильнее, через RawSQL / Func-выражения внутри method-фильтра.
Raw-запрос в фильтре — это признание, что ORM не хватило. Иногда это правда, но чаще под rawом прячется аннотация, которую поленились написать. Проверьте оба варианта прежде, чем вклеивать строку SQL в пользовательский эндпоинт.
Главная опасность здесь — параметры. Никогда не форматируйте пользовательский ввод в строку SQL через f-строки или .format(). Передавайте значения только через параметры выражения, иначе получите SQL-инъекцию через фильтр списка.
Сравнение подходов
| Подход | Когда подходит | Что выигрываете | Чем платите |
|---|---|---|---|
| filterset_fields | Точное совпадение по полям модели | Одна строка кода | Только равенство и заданные лукапы |
| Декларативный FilterSet | Диапазоны, лукапы, переименование параметров | Валидация из коробки, читаемость | Поле на каждый фильтр |
| Method-фильтр | Логика вне полей модели | Полный контроль над queryset | Валидацию и производительность держите сами |
| Фильтр по аннотации | Вычисляемые агрегаты, счётчики | Фильтрация на стороне БД (HAVING) | Аннотация считается всегда |
| Raw SQL | Оконные функции, спец. операторы БД | То, что ORM не умеет | Риск инъекции, привязка к СУБД |
Когда что выбирать
- Параметр совпадает с полем модели и нужно равенство —
filterset_fields, не усложняйте. - Нужен диапазон,
icontains,inили другое имя параметра, чем поле — декларативныйFilterSetс явными полями. - Условие зависит от текущего времени, прав пользователя или комбинации полей — method-фильтр.
- Фильтруете по
Sum,Count,Exists— аннотация в queryset плюс обычное поле фильтра, ссылающееся на неё. - ORM честно не выражает условие и вы это проверили — RawSQL с параметрами, и только тогда.
Что ломается на практике
Три ошибки, которые всплывают чаще всего.
- Фильтр по related-полю без select_related/prefetch_related. Фильтрация сработает, но сериализация выдаст N+1. Фильтр и оптимизация выборки — разные вещи, django-filter за вас
prefetchне поставит. - Method-фильтр без проверки на пустое значение. Если параметр не пришёл, django-filter обычно не вызывает метод, но при
NullBooleanFilterи кастомных виджетахvalueможет прийти пустым — обработайтеif not value: return querysetявно. - Фильтр по аннотации вешает HAVING на весь список. При больших таблицах агрегат по группам без нужных индексов даёт полный проход. Смотрите
EXPLAIN ANALYZE, а не только время ответа в разработке.
Отдельно: DjangoFilterBackend по умолчанию не отклоняет неизвестные query-параметры. Опечатку в имени фильтра клиент не заметит — запрос вернёт весь список, будто фильтр не задавали. Строгую проверку неизвестных параметров настраивайте отдельно; поведение и настройки сверяйте с документацией вашей версии django-filter.
Что делать дальше
Соберите список реальных query-параметров, которые уже шлёт фронт, и разложите их по таблице выше — обычно 80% укладываются в декларативный FilterSet, а руками писать нужно два-три фильтра. После этого прогоните тяжёлые списки через django-debug-toolbar или EXPLAIN ANALYZE и посмотрите, не появился ли N+1 или полный проход по таблице от аннотации. Следующий показатель, за которым стоит следить в проде, — время ответа списочного эндпоинта на 95-м перцентиле при типичных комбинациях фильтров, а не на пустом фильтре.
Транскрибатор встреч: Запись созвона или диктофона — в текст с разделением по спикерам, протоколом и списком задач.
Спросить за 25 ₽Источники: django-filter documentation, Django REST Framework — Filtering
Частые вопросы
У меня django-filter другой версии, синтаксис отличается?
Фильтр не срабатывает, список приходит целиком — почему?
Как фильтровать по полю связанной модели?
Можно ли валидировать значение в method-фильтре?
Опасно ли использовать RawSQL в фильтре в продакшене?
Аннотация в get_queryset замедляет все запросы, а не только с фильтром?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.