Свои Manager и QuerySet в Django: где ломается логика фильтров
На активной модели с флагами is_published и deleted_at фильтры расползаются по вью и дублируются в API. Разбираем, почему кастомный Manager без QuerySet не спасает и где цепочка методов молча теряет условия.

В проекте на Django 5.x есть модель Article с полями is_published, deleted_at и author. Через полгода в кодовой базе двадцать три места, где написано Article.objects.filter(is_published=True, deleted_at__isnull=True): во вью, в сериализаторах DRF, в management-команде рассылки, в админском отчёте. Кто-то добавил мягкое удаление, забыл про один вью — и удалённые статьи полезли в RSS. Такой класс ошибок не ловится тестами, пока не прод. Разберём, чем менеджер отличается от QuerySet, почему одного менеджера мало и где цепочка .filter().filter() ведёт себя не так, как ждёшь.
Две сущности, которые путают
В Django за доступ к данным отвечают два разных объекта. Manager — это то, что висит на Article.objects и служит точкой входа с уровня класса модели. QuerySet — это то, что возвращается из .all(), .filter(), .exclude() и что можно продолжать сцеплять.
Ключевое отличие в цепочках. Методы менеджера доступны только на самом менеджере: Article.objects.published() сработает, а Article.objects.filter(author=user).published() — уже нет, потому что .filter() вернул обычный QuerySet, на котором метода published() нет. Именно поэтому кастомный менеджер сам по себе почти всегда оказывается недостаточным.
Метод менеджера — это вход в один конец. Метод QuerySet — это звено, которое можно ставить в любую позицию цепочки. Бизнес-логику фильтрации держат в QuerySet, а не в менеджере.
Наивный подход: только менеджер
Первое, что делают, — выносят фильтр в менеджер:
# Django 5.x
from django.db import models
class ArticleManager(models.Manager):
def published(self):
return self.filter(is_published=True, deleted_at__isnull=True)
class Article(models.Model):
is_published = models.BooleanField(default=False)
deleted_at = models.DateTimeField(null=True, blank=True)
author = models.ForeignKey(
"auth.User", on_delete=models.CASCADE, related_name="articles"
)
objects = ArticleManager()
Теперь Article.objects.published() работает. Но как только понадобилось ограничить по автору, вы упираетесь:
# Так — ошибка: у QuerySet нет метода published()
Article.objects.filter(author=user).published()
# AttributeError: 'QuerySet' object has no attribute 'published'
# Приходится городить обратно
Article.objects.published().filter(author=user) # порядок жёстко зафиксирован
Логика снова расползается: где-то пишут published().filter(...), где-то забывают и повторяют условие руками. Менеджер решил только половину задачи.
Рабочий подход: QuerySet как источник методов
Правильное место для published() — на классе QuerySet. Оттуда метод попадает в любую точку цепочки, потому что каждый метод QuerySet возвращает такой же QuerySet.
# Django 5.x
from django.db import models
from django.utils import timezone
class ArticleQuerySet(models.QuerySet):
def published(self):
return self.filter(is_published=True, deleted_at__isnull=True)
def by_author(self, user):
return self.filter(author=user)
def recent(self, days=7):
edge = timezone.now() - timezone.timedelta(days=days)
return self.filter(created_at__gte=edge)
class Article(models.Model):
is_published = models.BooleanField(default=False)
deleted_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
author = models.ForeignKey(
"auth.User", on_delete=models.CASCADE, related_name="articles"
)
# Менеджер, собранный из QuerySet
objects = ArticleQuerySet.as_manager()
Теперь работает в любом порядке и любой длины цепочка:
Article.objects.published().by_author(user).recent(30)
Article.objects.by_author(user).published()
Article.objects.recent().published().order_by("-created_at")
as_manager() создаёт менеджер, у которого все публичные методы QuerySet проброшены на уровень класса. Двадцать три повтора фильтра схлопываются в один вызов .published(), который читается вслух.
Когда нужны и свой менеджер, и свой QuerySet
Иногда менеджер должен делать что-то, чего у QuerySet быть не может, — например, менять базовый набор строк. Классический случай — мягкое удаление: хочется, чтобы Article.objects по умолчанию не отдавал удалённые записи, а отдельный менеджер all_objects видел всё.
# Django 5.x
class ArticleQuerySet(models.QuerySet):
def published(self):
return self.filter(is_published=True)
def alive(self):
return self.filter(deleted_at__isnull=True)
class AliveManager(models.Manager):
def get_queryset(self):
# базовый набор уже без удалённых
return ArticleQuerySet(self.model, using=self._db).alive()
class Article(models.Model):
is_published = models.BooleanField(default=False)
deleted_at = models.DateTimeField(null=True, blank=True)
objects = AliveManager() # без удалённых
all_objects = ArticleQuerySet.as_manager() # видит всё
Здесь важна деталь: методы QuerySet (published, alive) не пробросятся автоматически на AliveManager, потому что вы переопределили get_queryset вручную. Чтобы Article.objects.published() продолжало работать, менеджер собирают через Manager.from_queryset:
AliveManager = ArticleQuerySet.as_manager()
# либо явно:
class AliveManager(models.Manager.from_queryset(ArticleQuerySet)):
def get_queryset(self):
return super().get_queryset().alive()
Сравнение подходов
| Подход | Цепочки работают в любом месте | Меняет базовый набор строк | Когда брать |
|---|---|---|---|
| Только Manager с методом | Нет, только первым звеном | Да, через get_queryset | Почти никогда — устаревший приём |
| QuerySet.as_manager() | Да | Нет (базовый набор — все строки) | Большинство случаев с фильтрами |
| Manager.from_queryset() + get_queryset | Да | Да | Мягкое удаление, мультитенантность |
Где это ломается
Дефолтный менеджер и связи. Первый объявленный в модели менеджер становится _default_manager. Если он скрывает часть строк (как AliveManager выше), то обращения через related-объекты и в админке начнут терять записи неожиданным образом. При мягком удалении держите менеджер, отдающий все строки, объявленным первым, либо явно задавайте Meta.default_manager_name. Иначе миграции и dumpdata увидят урезанный набор.
Fat-методы на QuerySet, которые не возвращают QuerySet. Метод, который делает .count() или .first(), обрывает цепочку — это уже не звено, а терминатор. Такие методы называют так, чтобы было видно: published_count(), а не published().
Комбинация exclude и filter. Цепочка .filter(a=1).filter(b=2) и .filter(a=1, b=2) дают одинаковый SQL для полей самой модели, но расходятся при фильтрации через отношение многие-ко-многим или обратный FK: два раздельных .filter() порождают два JOIN и разную выборку. Если ваш метод QuerySet фильтрует по связанной модели, тестируйте именно цепочку, а не одиночный вызов.
use_for_related_fields больше нет. Атрибут удалён давно; управление менеджером для related-доступа делается через base_manager_name в Meta. Не ищите старый флаг в свежих туториалах.
Что делать дальше
- Найдите в проекте повторяющиеся
.filter(...)с одинаковыми условиями (grep по имени поля-флага) — это кандидаты в методы QuerySet. - Перенесите условия в класс QuerySet, соберите менеджер через
as_manager()и замените повторы на именованные вызовы. - Проверьте, какой менеджер объявлен первым: от этого зависит поведение админки, сериализации и связей.
- Посмотрите на генерируемый SQL через
str(qs.query)для цепочек по связанным моделям — убедитесь, что число JOIN то, что вы ждёте.
Аннотации и агрегации на кастомном QuerySet (.annotate() внутри метода) — отдельная тема со своими ловушками по дублированию строк, здесь мы её не трогаем.
Репетитор 1—11 класс и ВУЗ: Математика и физика методом наведения на мысль, помощь с ДЗ.
Спросить за 8 ₽Источники: Django docs: Managers, Django docs: QuerySet API reference
Частые вопросы
Чем as_manager() отличается от from_queryset()?
У меня Django 3.2 / 4.x, всё это будет работать?
Почему мой метод QuerySet не виден на Article.objects?
Цепочка .published().by_author() тянет один запрос или несколько?
Что выбрать для мягкого удаления, чтобы не поймать баг в проде?
Не сработало: два .filter() по связанной модели дают лишние строки. Почему?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.