Фильтрация списков в Django: типичные ошибки ORM
Фильтр по связанным полям через несколько .filter() возвращает не то, что ждёшь, а один невинный вызов внутри цикла разворачивается в сотни SQL-запросов. Разбираем ошибки, которые бьют по корректности и по нагрузке.

Задача звучит просто: показать список объектов, отфильтрованный по нескольким условиям. Но ORM Django ведёт себя не так, как читается код на Python. Два подряд идущих .filter() по связанной модели дают другой результат, чем один .filter() с двумя условиями. А обращение к связанному объекту внутри цикла шаблона превращает одну страницу в 300 запросов к базе. Ниже — ошибки, которые встречаются в ревью чаще всего, с ценой каждой в корректности и в миллисекундах. Всё проверялось на Django 5.x и PostgreSQL 16, но поведение ORM в этих местах не менялось со времён Django 2.x.
Ошибка 1. Несколько filter() по одной связи — это не «И»
Самая коварная. Есть Author и связанные с ним Book (ForeignKey от книги к автору). Нужно найти авторов, у которых есть книга жанра «fiction» и книга с рейтингом выше 4. Инстинктивно пишут так:
# НЕВЕРНО: два filter() по связи books
authors = Author.objects.filter(
books__genre="fiction"
).filter(
books__rating__gt=4
)
Кажется, что условия складываются в «И» по одной и той же книге. На деле каждый .filter() порождает отдельный JOIN. Запрос вернёт авторов, у которых есть хоть какая-то книга в жанре fiction и хоть какая-то (возможно, другая) книга с рейтингом выше 4. Это не то же самое, что «книга и в жанре fiction, и с рейтингом выше 4».
Если нужно совпадение внутри одной связанной записи — оба условия должны жить в одном .filter():
# ВЕРНО: оба условия относятся к одной и той же книге
authors = Author.objects.filter(
books__genre="fiction",
books__rating__gt=4,
)
Правило простое: если условия должны выполняться для одной и той же связанной записи — держите их в одном filter(). Если условия про разные записи — разбивайте на цепочку. Это не стилистика, это разный SQL и разный результат.
Поведение описано в разделе документации «Spanning multi-valued relationships» и не менялось годами — но код с двумя .filter() продолжает проходить в прод, потому что на маленьких данных ошибка не видна.
Ошибка 2. Запрос внутри цикла — проблема N+1
Классика, которая не лечится опытом сама собой. В шаблоне или в цикле по QuerySet обращаетесь к связанному объекту:
# Один запрос на список книг
books = Book.objects.all()
for book in books:
# каждое обращение к book.author — отдельный SQL-запрос
print(book.author.name)
На 300 книгах это 1 запрос за список плюс 300 запросов за авторов. В логах django-debug-toolbar вы увидите сотни почти одинаковых строк SELECT ... FROM author WHERE id = %s.
Лечится подгрузкой связей заранее:
- select_related — для ForeignKey и OneToOne. Делает JOIN, тянет связанные строки одним запросом.
- prefetch_related — для ManyToMany и обратных ForeignKey. Делает отдельный второй запрос и склеивает данные в Python.
# author подтянется тем же запросом через JOIN
books = Book.objects.select_related("author")
for book in books:
print(book.author.name) # без дополнительных запросов
Для обратной связи (у автора много книг) наоборот:
# books подтянутся вторым запросом и разложатся по авторам
authors = Author.objects.prefetch_related("books")
Как отличить, что использовать
| Тип связи | Метод | Что делает с SQL |
|---|---|---|
| ForeignKey (объект → один родитель) | select_related | Один запрос с JOIN |
| OneToOne | select_related | Один запрос с JOIN |
| ManyToMany | prefetch_related | Второй запрос, склейка в Python |
| Обратный ForeignKey (родитель → много детей) | prefetch_related | Второй запрос, склейка в Python |
Ошибка 3. count() и len() решают одну задачу по-разному
Нужно узнать число объектов в отфильтрованном списке. Два варианта выглядят одинаково, но дают разную нагрузку:
qs = Book.objects.filter(genre="fiction")
# Вариант А: SELECT COUNT(*) — база считает, в Python приходит число
qs.count()
# Вариант Б: вытянуть все строки в память и посчитать len
len(qs)
Если вам нужны и сами объекты (вы всё равно будете по ним итерировать в этом же коде), то len() уже загруженного QuerySet не делает лишнего запроса — данные в кеше. Но если объекты не нужны, а нужно только число, count() заметно дешевле: база не гоняет строки по сети и не материализует их в Python. На таблице в сотни тысяч строк разница между «посчитать в БД» и «вытащить всё и посчитать в Python» — секунды против миллисекунд.
Ошибка 4. exists() против if qs
Проверка «есть ли хоть один объект» часто пишется как истинностная проверка QuerySet:
# Материализует весь QuerySet, чтобы понять, пустой ли он
if Book.objects.filter(author=some_author):
...
# SELECT 1 ... LIMIT 1 — база отвечает сразу
if Book.objects.filter(author=some_author).exists():
...
Первый вариант тянет из базы все подходящие строки только ради ответа «да/нет». exists() генерирует запрос с LIMIT 1 и возвращает булево значение. На больших выборках разница ощутима.
Ошибка 5. Фильтрация по значению, которое можно вычислить в базе
Иногда фильтр пишут в Python после выборки, хотя условие вычислимо в SQL:
# Тянем всё, фильтруем в цикле
books = [b for b in Book.objects.all() if b.rating > 4]
# База отдаёт только нужное
books = Book.objects.filter(rating__gt=4)
Первый вариант загружает всю таблицу в память приложения. На росте данных это первое, что упрётся в лимит памяти воркера. Правило: всё, что можно выразить через lookup (__gt, __in, __contains, __isnull), должно выполняться на стороне базы.
Ошибка 6. Фильтр по вычисляемому полю без annotate
Нужно отфильтровать авторов по числу их книг. Считать в Python — снова N+1 и лишняя память. Django умеет считать это в базе через annotate и агрегатные функции:
from django.db.models import Count
# Авторы, у которых больше 3 книг — считается одним запросом
authors = (
Author.objects
.annotate(book_count=Count("books"))
.filter(book_count__gt=3)
)
После annotate вычисленное поле доступно и для .filter(), и для .order_by(). Одна тонкость: если в цепочке есть JOIN по многозначной связи, Count может задваивать строки — тогда пригодится Count("books", distinct=True).
Как ловить эти ошибки до прода
- Поставьте
django-debug-toolbarв dev-окружении и смотрите панель SQL на каждой странице со списком. Число запросов не должно расти вместе с числом строк в списке. - В тестах используйте
assertNumQueries(N)— он падает, если вьюха начала делать больше запросов, чем заложено. Это ловит появление N+1 при рефакторинге. - Для медленных запросов включите логирование SQL и посмотрите
EXPLAIN ANALYZEв самом PostgreSQL — Python-профайлер не покажет, что тормозит именно план запроса.
Что делать дальше
Возьмите одну страницу со списком, откройте панель SQL в debug-toolbar и посчитайте запросы. Если их число зависит от числа элементов в списке — у вас N+1, начинайте с select_related / prefetch_related. Дальше стоит завести в тестах assertNumQueries для критичных вьюх, чтобы регрессия по числу запросов не проходила в прод незамеченной. Инвалидация и кеширование QuerySet — отдельная тема, здесь мы разбирали только сами фильтры.
Транскрибатор встреч: Запись созвона или диктофона — в текст с разделением по спикерам, протоколом и списком задач.
Спросить за 25 ₽Источники: Django QuerySet API reference — select_related, prefetch_related, annotate, Making queries — Spanning multi-valued relationships
Частые вопросы
У меня Django 3.2, эти приёмы сработают?
Я добавил select_related, а запросов всё равно много. Почему?
Два filter() подряд — это всегда ошибка?
Насколько exists() быстрее, чем if с QuerySet?
Как понять в проде, что где-то остался N+1?
annotate с Count даёт неверные числа, что не так?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.