Фильтры django-filter на практике: рабочие приёмы
Как построить фильтрацию в DRF на django-filter без лишних запросов к базе, с корректной фильтрацией по датам, диапазонам и связанным моделям — и где типовой FilterSet ломается на проде.

Вы выводите список заказов в API на Django REST Framework и хотите дать клиенту фильтры: по статусу, по диапазону дат, по имени покупателя. Первое, что приходит в голову — руками разбирать request.query_params и добавлять .filter(). Через месяц это превращается в километровый get_queryset с десятком if. django-filter решает ту же задачу декларативно, но у него есть неочевидные места: фильтр по DateTimeField «съедает» весь день, filter_overrides молча игнорируется, а фильтрация по связанным моделям устраивает N+1. Разберём приёмы, которые реально экономят время, и цену каждого.
Всё ниже проверено на django-filter 24.x и Django 5.x с DRF 3.15. API самого пакета стабилен уже несколько мажорных версий, но точные имена классов сверяйте с документацией вашей версии — ссылка в конце.
Базовый FilterSet и почему он не так прост
Минимальный вариант выглядит безобидно:
import django_filters
from .models import Order
class OrderFilter(django_filters.FilterSet):
class Meta:
model = Order
fields = ['status', 'created_at', 'customer']
Проблема в том, что для created_at типа DateTimeField django-filter создаст фильтр точного совпадения. Запрос ?created_at=2024-06-01 будет искать записи с моментом времени ровно 2024-06-01 00:00:00 — и не найдёт ничего, потому что реальные заказы имеют секунды и микросекунды. Пользователь видит пустой список и уверен, что данных нет.
Второй скрытый эффект: перечисление полей строкой в fields лишает вас контроля над тем, какой lookup доступен. Как только вам нужно gte или icontains, декларативный список перестаёт помогать.
Диапазоны дат: три подхода
Задача — отдать заказы за период. Есть три рабочих способа, и они не равноценны.
1. Два отдельных фильтра gte/lte
class OrderFilter(django_filters.FilterSet):
created_after = django_filters.DateFilter(
field_name='created_at', lookup_expr='gte'
)
created_before = django_filters.DateFilter(
field_name='created_at', lookup_expr='lte'
)
class Meta:
model = Order
fields = ['status']
Запрос: ?created_after=2024-06-01&created_before=2024-06-30. Явно, читается в URL, но есть подвох. DateFilter с lte по полю DateTimeField сравнивает с 2024-06-30 00:00:00 — то есть отсекает весь последний день. Заказ от 30 июня в 14:00 в выборку не попадёт. Это самая частая жалоба в трекерах по django-filter.
2. DateFromToRangeFilter
class OrderFilter(django_filters.FilterSet):
created_at = django_filters.DateFromToRangeFilter()
class Meta:
model = Order
fields = ['status']
Запрос: ?created_at_after=2024-06-01&created_at_before=2024-06-30. Компактнее, один фильтр вместо двух. Но проблема с границей дня остаётся — суффикс _before так же режет по началу суток.
3. Корректная граница дня через кастомный метод
Чтобы «30 июня» означало включительно весь день, сдвигайте верхнюю границу на сутки вперёд и используйте строгое lt:
from datetime import timedelta
class OrderFilter(django_filters.FilterSet):
created_before = django_filters.DateFilter(method='filter_created_before')
def filter_created_before(self, queryset, name, value):
# value — это date; берём всё ДО начала следующего дня
return queryset.filter(created_at__lt=value + timedelta(days=1))
class Meta:
model = Order
fields = ['status']
Этот вариант единственный не теряет заказы последнего дня. Цена — вы пишете метод руками и теряете автоматическую генерацию виджета для DRF-браузера, но для реальных клиентов это несущественно.
| Подход | URL | Граница дня | Когда брать |
|---|---|---|---|
| Два DateFilter gte/lte | created_after / created_before | Режет последний день | Фильтр по чистой дате (DateField) |
| DateFromToRangeFilter | created_at_after / _before | Режет последний день | Быстрый прототип, DateField |
| Кастомный method + lt | created_before | Включает весь день | DateTimeField в проде |
Если поле хранит момент времени, а фильтр принимает дату — граница «до» почти всегда должна быть строгим сравнением со следующими сутками, иначе вы молча теряете записи последнего дня диапазона.
Фильтрация по связанным моделям без N+1
Допустим, нужно фильтровать заказы по названию категории товара — а это связь через несколько таблиц. django-filter спокойно принимает lookup через __:
class OrderFilter(django_filters.FilterSet):
category = django_filters.CharFilter(
field_name='items__product__category__name',
lookup_expr='icontains',
)
class Meta:
model = Order
fields = ['status']
Фильтр отработает, но с двумя эффектами, которые видно только под нагрузкой. Первый: JOIN через many-to-many (items) даёт дубликаты строк — один заказ с тремя подходящими товарами вернётся трижды. Лечится на уровне queryset представления через .distinct(). Второй: сам фильтр не решает проблему N+1 при последующей сериализации — за это отвечает select_related/prefetch_related в get_queryset, а не FilterSet. django-filter только добавляет условие в WHERE, оптимизацию выборки он на себя не берёт.
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
filterset_class = OrderFilter
def get_queryset(self):
return (
Order.objects
.select_related('customer')
.prefetch_related('items__product__category')
.distinct()
)
Множественный выбор и «или» вместо «и»
Клиент хочет заказы в статусах «новый» или «в работе» одним запросом. Обычный ChoiceFilter примет только одно значение. Нужен MultipleChoiceFilter:
class OrderFilter(django_filters.FilterSet):
status = django_filters.MultipleChoiceFilter(
choices=Order.STATUS_CHOICES,
# conjoined=False (по умолчанию) — это OR между значениями
)
class Meta:
model = Order
fields = []
Запрос: ?status=new&status=in_progress вернёт объединение. Параметр conjoined=True меняет логику на «И» — полезно для тегов, где нужно совпадение по всем сразу. Обратите внимание: MultipleChoiceFilter валидирует значения по choices, и неизвестный статус вернёт ошибку валидации, а не молча пустой список. Это правильное поведение — клиент узнаёт об опечатке.
Когда что выбирать
- Точное совпадение по строке/числу — оставляйте генерацию через
Meta.fields, писать вручную нечего. - Поиск по подстроке — явный
CharFilter(lookup_expr='icontains'). Помните, чтоicontainsпо большой таблице без функционального индекса — это seq scan. - Диапазон дат по DateTimeField — кастомный метод с
__ltна верхней границе. - Несколько значений одного поля —
MultipleChoiceFilter, не городите__inруками из query-параметров. - Сложная логика с несколькими полями сразу —
method=у фильтра или переопределениеfilter_queryset. Не пытайтесь выразить всё декларативно.
Что ломается на практике
Три ошибки встречаются чаще прочих.
- Пустой fields без явных фильтров. Если задать
fields = '__all__', django-filter сгенерирует фильтры по всем полям модели, включая те, что вы не хотели выставлять наружу. Перечисляйте поля явно. - Дубликаты после JOIN. Фильтр по m2m без
.distinct()раздувает выдачу и ломает пагинацию — на второй странице появляются те же записи. - Тихое игнорирование неизвестного параметра. По умолчанию лишний query-параметр django-filter просто пропускает. Клиент опечатался в
?stauts=new— получил весь список без фильтра и решил, что фильтрация сломана. Включите строгую валидацию через настройкуFILTERS_STRICTNESS, если хотите отвергать неизвестные параметры (точное имя и значения сверьте в документации вашей версии).
Что настроить дальше
После того как FilterSet заработал, посмотрите на три вещи в проде. Включите логирование SQL (django.db.backends в DEBUG) и проверьте, что фильтр по связям не породил лишних JOIN. Прогоните EXPLAIN на запросах с icontains — под них обычно нужен GIN-индекс с pg_trgm в PostgreSQL, иначе фильтр деградирует линейно от размера таблицы. И вынесите общие фильтры (даты, статусы) в базовый FilterSet-миксин, если у вас несколько похожих ресурсов — дублирование полей по вьюхам расползается быстро.
Prompt-инженер: Идеальные запросы для Midjourney, ChatGPT и других моделей.
Спросить за 15 ₽Источники: django-filter — официальная документация, Django REST Framework — Filtering
Частые вопросы
У меня django-filter старее 24, приёмы сработают?
Фильтр по дате возвращает пустой список, хотя данные есть — почему?
После фильтрации по связанной модели заказы дублируются в выдаче
Как отдать клиенту фильтр «или» по нескольким статусам?
django-filter игнорирует мой параметр и не фильтрует — что не так?
Решает ли django-filter проблему N+1 запросов?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.