Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • Post.objects.published() instead of copy-paste
  • QuerySet subclasses and as_manager()
  • Several managers on one model
  • Default manager and its effect on relations

Lesson notes

1. The filter you have already copied three times

Look through your views. This line, or something very close to it, appears everywhere:

# blog/views.py
def post_list(request):
    posts = Post.objects.filter(
        published_date__lte=timezone.now()
    ).order_by("-published_date")
    return render(request, "blog/post_list.html", {"posts": posts})


def category_detail(request, slug):
    category = get_object_or_404(Category, slug=slug)
    posts = category.posts.filter(
        published_date__lte=timezone.now()
    ).order_by("-published_date")
    ...


def feed(request):
    posts = Post.objects.filter(
        published_date__lte=timezone.now()
    ).order_by("-published_date")[:20]
    ...

The definition of "published" is now spelled out in three places. On the day you add a status field or decide drafts should also be hidden from the sitemap, you have to find every one of them - and you will miss one, and it will be the one that leaks unpublished posts.

A manager is where that knowledge belongs. The rule: a view describes what the page needs; the model describes what the words mean.

2. Manager, QuerySet, and objects

objects is not magic. It is a class attribute holding a Manager instance, added automatically if you do not declare one. Its job is to hand you a fresh QuerySet; the QuerySet is what actually knows how to filter.

Post.objects a Manager get_queryset() PostQuerySet published() in_category(slug) returns PostQuerySet same class, so it chains again .published().in_category("django").order_by("-published_date") one per model class

3. The smallest useful manager

# blog/models.py
from django.db import models
from django.utils import timezone


class PublishedManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(published_date__lte=timezone.now())


class Post(models.Model):
    ...
    objects = models.Manager()          # keep the default, explicitly
    published = PublishedManager()
Post.published.all()          # only live posts
Post.published.count()
Post.objects.all()            # still everything, including drafts

This approach has a real limitation: published is a filter you can only apply at the start. There is no way to say "of these posts, the published ones", and you cannot add a second concept like in_category() without writing methods that return plain QuerySets and therefore stop chaining.

4. QuerySet subclasses and as_manager()

Put the methods on the QuerySet instead. Because each one returns self.filter(...), which is still a PostQuerySet, they compose in any order:

# blog/managers.py
from django.db import models
from django.db.models import Count, Q
from django.utils import timezone


class PostQuerySet(models.QuerySet):
    def published(self):
        return self.filter(published_date__lte=timezone.now())

    def drafts(self):
        return self.filter(published_date__isnull=True)

    def in_category(self, slug):
        return self.filter(category__slug=slug)

    def tagged(self, *slugs):
        return self.filter(tags__slug__in=slugs).distinct()

    def with_comment_counts(self):
        return self.annotate(
            num_comments=Count("comments", filter=Q(comments__approved=True))
        )

    def search(self, term):
        if not term:
            return self
        return self.filter(Q(title__icontains=term) | Q(text__icontains=term))

    def newest_first(self):
        return self.order_by("-published_date")
# blog/models.py
from .managers import PostQuerySet


class Post(models.Model):
    ...
    objects = PostQuerySet.as_manager()

The views collapse into sentences:

def post_list(request):
    posts = (
        Post.objects.published()
        .with_comment_counts()
        .newest_first()
    )
    return render(request, "blog/post_list.html", {"posts": posts})


def category_detail(request, slug):
    category = get_object_or_404(Category, slug=slug)
    posts = category.posts.published().newest_first()
    ...

Note the second one: because the reverse accessor uses the model's default manager class, category.posts also has published(). That is the payoff of putting the logic on the QuerySet.

5. When you need a manager and a queryset

as_manager() copies the QuerySet methods onto a manager, but not the other way around. If you also want manager-only behaviour - a different base queryset, or a helper that does not return a QuerySet - use from_queryset():

class PostManager(models.Manager.from_queryset(PostQuerySet)):
    def get_queryset(self):
        return super().get_queryset().select_related("author", "category")

    def create_draft(self, author, title, text):
        return self.create(author=author, title=title, text=text, published_date=None)


class Post(models.Model):
    ...
    objects = PostManager()
You wantUse
chainable query methods onlyPostQuerySet.as_manager()
chainable methods plus manager helpersManager.from_queryset(PostQuerySet)
a permanently narrowed set of rowsa Manager overriding get_queryset()

6. Several managers on one model

Order matters. The first manager declared becomes Model._default_manager, and that is the one Django itself uses - the admin, dumpdata, related object lookups and generic views:

class Post(models.Model):
    ...
    objects = PostQuerySet.as_manager()      # first = default manager
    published = PublishedManager()

    class Meta:
        base_manager_name = "objects"
        default_manager_name = "objects"

base_manager_name is the subtler of the two. It is the manager Django uses when it follows a relation to fetch a single related object, for example comment.post. If that manager filters rows out, following the relation can raise Post.DoesNotExist for a row that plainly exists.

7. Test the manager, not the view

Once the rule lives in one place, one test protects the whole site:

# blog/tests/test_managers.py
from datetime import timedelta

from django.test import TestCase
from django.utils import timezone

from blog.models import Post


class PostQuerySetTests(TestCase):
    def setUp(self):
        self.author = User.objects.create_user("ada")
        self.live = Post.objects.create(
            author=self.author, title="Live", text="x",
            published_date=timezone.now() - timedelta(days=1),
        )
        self.draft = Post.objects.create(author=self.author, title="Draft", text="x")
        self.future = Post.objects.create(
            author=self.author, title="Later", text="x",
            published_date=timezone.now() + timedelta(days=1),
        )

    def test_published_excludes_drafts_and_future(self):
        self.assertQuerySetEqual(Post.objects.published(), [self.live])

    def test_methods_chain_in_any_order(self):
        self.assertEqual(
            list(Post.objects.published().search("Live")),
            list(Post.objects.search("Live").published()),
        )

8. Exercise

  1. Create blog/managers.py with a PostQuerySet that has published(), drafts() and newest_first(), and wire it up with as_manager().
  2. Replace every published_date__lte=timezone.now() in your views and feeds with published(). Grep the project afterwards to prove none is left.
  3. Add in_category(slug) and tagged(*slugs), then write one expression returning published posts in "django" tagged "orm", newest first.
  4. Add a CommentQuerySet with approved() and pending(), and use post.comments.approved() in the detail template's context.
  5. Deliberately make PublishedManager the first manager on Post, open the admin, and describe what breaks. Then undo it.

Further reading