ZetCode

Django managers and custom QuerySets

last modified November 13, 2026 Python 3.15

In this article we put the queries of a model in one place with managers and custom QuerySets. We write a manager that returns only published articles, a QuerySet with chainable methods such as published() and recent(), create a manager from it with as_manager() and from_queryset(), and see how the default manager affects related objects. The examples use Django 6.1, Python 3.15, and the default SQLite database.

Managers

A manager is the interface through which database queries are made for a model. Every model has at least one, named objects by default, and that is what we use in Book.objects.filter(...). A manager is a class derived from models.Manager; its get_queryset() method returns the base QuerySet that all the other methods start from. A custom manager lets us change that base QuerySet, or add methods for common queries, instead of repeating the same filter() calls across views and templates. The ORM itself is described in Django QuerySets.

The example project

The project is created with uv; the steps are explained in Django introduction. The commands below create a project directory, install Django, generate the project with the configuration package config, start the app news, and create the folder demos for demo scripts.

$ mkdir managers
$ cd managers
$ uv init --bare
$ uv add django
$ uv run django-admin startproject config .
$ uv run python manage.py startapp news
$ mkdir demos

The trailing dot in startproject config . creates manage.py and the config package in the current directory instead of one level deeper. A new app is ignored by Django until it is listed in INSTALLED_APPS, so we add the app to the list in config/settings.py; the other entries are generated by startproject.

config/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'news',
]

The listings in this article are of two kinds. A listing with a solid blue border and the label Project file is a file of the application: models, views, templates, tests, and settings. A listing with a dashed amber border and the label Demo script is a small helper in the demos folder; it sets up Django, runs a few lines of code, and prints the results, so that we can watch the behaviour. Demo scripts are not part of the application; we run one from the project directory with uv run python -m demos.name.

The app has authors and articles; an article is a draft or published, and it has a publication date that may be in the future. The models are in the next listing, and the sections below return to its parts.

news/models.py
from datetime import timedelta

from django.db import models
from django.db.models import Count, Q
from django.utils import timezone


class AuthorQuerySet(models.QuerySet):

    def active(self):
        return self.filter(active=True)

    def with_counts(self):
        return self.annotate(
            total=Count('articles'),
            live=Count('articles', filter=Q(articles__status='published')),
        )


class Author(models.Model):
    name = models.CharField(max_length=100)
    active = models.BooleanField(default=True)

    objects = AuthorQuerySet.as_manager()

    class Meta:
        ordering = ['name']

    def __str__(self):
        return self.name


class ArticleQuerySet(models.QuerySet):

    def published(self):
        return self.filter(status=Article.Status.PUBLISHED, published_at__lte=timezone.now())

    def drafts(self):
        return self.filter(status=Article.Status.DRAFT)

    def by(self, author):
        return self.filter(author=author)

    def recent(self, days=30):
        return self.filter(published_at__gte=timezone.now() - timedelta(days=days))

    def search(self, text):
        return self.filter(Q(title__icontains=text) | Q(body__icontains=text))

    def publish(self):
        return self.drafts().update(status=Article.Status.PUBLISHED, published_at=timezone.now())


class PublishedManager(models.Manager):

    def get_queryset(self):
        return super().get_queryset().filter(
            status=Article.Status.PUBLISHED, published_at__lte=timezone.now())


class ArticleManager(models.Manager.from_queryset(ArticleQuerySet)):

    def create_draft(self, title, author, body=''):
        return self.create(title=title, author=author, body=body)


class Article(models.Model):

    class Status(models.TextChoices):
        DRAFT = 'draft', 'Draft'
        PUBLISHED = 'published', 'Published'

    title = models.CharField(max_length=200)
    body = models.TextField(blank=True)
    author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='articles')
    status = models.CharField(max_length=10, choices=Status, default=Status.DRAFT)
    published_at = models.DateTimeField(null=True, blank=True)

    objects = ArticleManager()
    live = PublishedManager()

    class Meta:
        ordering = ['-published_at', 'title']

    def __str__(self):
        return self.title


class VisibleManager(models.Manager):

    def get_queryset(self):
        return super().get_queryset().filter(hidden=False)


class Snippet(models.Model):
    author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='snippets')
    text = models.CharField(max_length=100)
    hidden = models.BooleanField(default=False)

    visible = VisibleManager()
    objects = models.Manager()


class Note(models.Model):
    author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='notes')
    text = models.CharField(max_length=100)
    hidden = models.BooleanField(default=False)

    visible = VisibleManager()
    objects = models.Manager()

    class Meta:
        default_manager_name = 'objects'

The Author and Article models have custom managers. The Snippet and Note models are identical except for the Meta option; they serve to demonstrate the default manager in the last sections. The tables are created with makemigrations and migrate (see Django migrations), and a script fills them with three authors and seven articles, one of which is scheduled for the future.

demos/seed.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from datetime import timedelta

from django.utils import timezone

from news.models import Article, Author, Note, Snippet

now = timezone.now()
ada = Author.objects.create(name='Ada')
linus = Author.objects.create(name='Linus')
bob = Author.objects.create(name='Old Bob', active=False)

rows = [
    ('Django managers', 'How managers work', ada, 'published', 3),
    ('Django signals', 'Signals in Django', ada, 'published', 90),
    ('Python typing', 'Type hints', ada, 'draft', None),
    ('Linux pipes', 'Pipes and filters', linus, 'published', 10),
    ('Kernel notes', 'Notes about the kernel', linus, 'draft', None),
    ('Retro computing', 'Old machines', bob, 'published', 400),
    ('Future article', 'Scheduled for later', ada, 'published', -5),
]
for title, body, author, status, days_ago in rows:
    Article.objects.create(
        title=title, body=body, author=author, status=status,
        published_at=None if days_ago is None else now - timedelta(days=days_ago))

for model in (Snippet, Note):
    model.objects.create(author=ada, text='visible one')
    model.objects.create(author=ada, text='visible two')
    model.objects.create(author=ada, text='secret', hidden=True)

print(Author.objects.count(), 'authors,', Article.objects.count(), 'articles')

The script creates the articles with a number of days since the publication; the draft articles have no date, and the negative number puts the date of Future article in the future. It also creates three snippets and three notes for Ada; the hidden ones are used later.

$ uv run python manage.py makemigrations news
Migrations for 'news':
  news/migrations/0001_initial.py
    + Create model Author
    + Create model Article
    + Create model Note
    + Create model Snippet
$ uv run python manage.py migrate news
Operations to perform:
  Apply all migrations: news
Running migrations:
  Applying news.0001_initial... OK
$ uv run python -m demos.seed
3 authors, 7 articles

A custom manager

The simplest customization overrides get_queryset(). The manager PublishedManager returns only the articles that are published and whose date is not in the future. It is attached to the model under the name live, next to objects, so both ways of access exist. A manager can also have methods of its own, which do not return QuerySets; the create_draft() method is a shortcut for creating an object.

class PublishedManager(models.Manager):

    def get_queryset(self):
        return super().get_queryset().filter(
            status=Article.Status.PUBLISHED, published_at__lte=timezone.now())

class Article(models.Model):
    ...
    objects = ArticleManager()
    live = PublishedManager()

The manager is created once, when the model class is created, but get_queryset() runs on each use, so timezone.now() is current every time.

demos/manager.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from news.models import Article, Author

print([article.title for article in Article.live.all()])
print(Article.live.count(), Article.objects.count())

ada = Author.objects.get(name='Ada')
draft = Article.objects.create_draft('Raw SQL', ada, 'A draft')
print(draft.status, Article.objects.drafts().count())

try:
    Article.live.drafts()
except AttributeError as error:
    print('AttributeError:', error)

print(Article._default_manager.name)
print(type(Article.objects).__name__, type(Article.live).__name__)

The script reads the published articles through live; the article scheduled for the future is not among them, so there are 4 of 7. The create_draft() shortcut makes a draft, and there are now three of them. The last calls show the limit of this approach: a method defined on the QuerySet class, such as drafts(), is not available on the plain PublishedManager, which raises AttributeError. The default manager of the model is objects, because it is declared first. The last line prints the class names of the two managers.

$ uv run python -m demos.manager
['Django managers', 'Linux pipes', 'Django signals', 'Retro computing']
4 7
draft 3
AttributeError: 'PublishedManager' object has no attribute 'drafts'
objects
ArticleManager PublishedManager

Custom QuerySets

Methods on a manager are not chainable: Article.live.recent() works only if the manager has recent(), and what it returns is a QuerySet that knows nothing of our methods. The solution is to define the methods on a QuerySet subclass. Every method returns a QuerySet of the same class, so the calls chain in any order: published().recent().by(author). There are two ways to get a manager from it. The as_manager() class method creates a manager with copies of all the public QuerySet methods; this is what Author uses. When the manager needs methods of its own, as ArticleManager does with create_draft(), we subclass the class returned by Manager.from_queryset(). Methods starting with an underscore are not copied, and neither are those marked with queryset_only = True.

class ArticleQuerySet(models.QuerySet):

    def published(self):
        return self.filter(status=Article.Status.PUBLISHED, published_at__lte=timezone.now())

    def recent(self, days=30):
        return self.filter(published_at__gte=timezone.now() - timedelta(days=days))

class ArticleManager(models.Manager.from_queryset(ArticleQuerySet)):

    def create_draft(self, title, author, body=''):
        return self.create(title=title, author=author, body=body)

class AuthorQuerySet(models.QuerySet):
    ...

class Author(models.Model):
    objects = AuthorQuerySet.as_manager()

The query methods are written once and are usable from the manager, from a chain of filters, and, as we see below, from related managers.

demos/queryset.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from news.models import Article, Author


def titles(queryset):
    return [article.title for article in queryset]


ada = Author.objects.get(name='Ada')

print(titles(Article.objects.published()))
print(titles(Article.objects.published().recent(30)))
print(titles(Article.objects.published().by(ada).search('django')))
print(titles(Article.objects.drafts().search('python')))

print(Article.objects.drafts().by(ada).search('python').query)

print(Article.objects.filter(author=ada).drafts().count(), 'drafts by Ada')
print(Article.objects.drafts().by(ada).publish(), 'draft published')
print(ada.articles.published().count(), 'published articles by Ada')

print('---')
for author in Author.objects.active().with_counts():
    print(author.name, author.total, author.live)
print(type(Author.objects).__name__)

The first queries chain published(), recent(), by(), and search() in different combinations. The printed SQL shows that the methods simply add conditions to one query. The call to publish() is a QuerySet method that updates all rows of the QuerySet, here the drafts of Ada, and returns the number of updated rows. The method is also available on the related manager: ada.articles.published() filters the articles of one author, and it counts four articles, because the one with a date in the future does not count. The last part uses the Author methods active() and with_counts(), which annotates the number of articles (see Django aggregation and annotation); the live count there checks only the status, so it includes the future article. The type of Author.objects is a class that Django generated from the QuerySet.

$ uv run python -m demos.queryset
['Django managers', 'Linux pipes', 'Django signals', 'Retro computing']
['Django managers', 'Linux pipes']
['Django managers', 'Django signals']
['Python typing']
SELECT "news_article"."id", "news_article"."title", "news_article"."body", "news_article"."author_id", "news_article"."status", "news_article"."published_at" FROM "news_article" WHERE ("news_article"."status" = draft AND "news_article"."author_id" = 1 AND ("news_article"."title" LIKE %python% ESCAPE '\' OR "news_article"."body" LIKE %python% ESCAPE '\')) ORDER BY "news_article"."published_at" DESC, "news_article"."title" ASC
2 drafts by Ada
2 draft published
4 published articles by Ada
---
Ada 5 5
Linus 2 1
ManagerFromAuthorQuerySet

The default manager

Django treats the first manager defined on a model as its default manager, available as Model._default_manager. It is more important than it looks: the default manager is used by related managers such as author.snippets, by the administration site, by serialization and by other generic code. When the first manager filters rows, all this code silently does not see them. This is why custom managers that filter should come after a plain objects manager, or the model should select its default with Meta.default_manager_name.

class Snippet(models.Model):
    ...
    visible = VisibleManager()
    objects = models.Manager()

class Note(models.Model):
    ...
    visible = VisibleManager()
    objects = models.Manager()

    class Meta:
        default_manager_name = 'objects'

The two models have the same managers in the same order. In Snippet the filtering manager is first, so it becomes the default. In Note the Meta option selects objects.

demos/default.py
import os

import django

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()

from news.models import Author, Note, Snippet

ada = Author.objects.get(name='Ada')

for model in (Snippet, Note):
    print(model.__name__, 'default manager:', model._default_manager.name)
    print('  objects:', model.objects.count(), ' visible:', model.visible.count())

print('Snippet via the related manager:', ada.snippets.count())
print('Note via the related manager:   ', ada.notes.count())

The script prints the default manager and the counts. In both models objects counts all three rows and visible two. The related manager makes the difference: ada.snippets.count() returns 2, as the hidden snippet disappears from the author's relation, while ada.notes.count() returns 3. A bug like that is hard to find, because no error is raised.

$ uv run python -m demos.default
Snippet default manager: visible
  objects: 3  visible: 2
Note default manager: objects
  objects: 3  visible: 2
Snippet via the related manager: 2
Note via the related manager:    3

Testing managers

The tests create articles with dates in the past and future and check the QuerySet methods, the chaining, the related managers, the manager-only method, and the effect of the default manager. Every test starts with a clean database.

news/tests.py
from datetime import timedelta

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

from .models import Article, Author, Snippet


class ManagerTests(TestCase):

    @classmethod
    def setUpTestData(cls):
        now = timezone.now()
        cls.ada = Author.objects.create(name='Ada')
        cls.bob = Author.objects.create(name='Bob', active=False)
        Article.objects.create(title='New', author=cls.ada, status='published',
                               published_at=now - timedelta(days=2))
        Article.objects.create(title='Old', author=cls.ada, status='published',
                               published_at=now - timedelta(days=200))
        Article.objects.create(title='Later', author=cls.ada, status='published',
                               published_at=now + timedelta(days=5))
        Article.objects.create(title='Draft', author=cls.bob)

    def titles(self, queryset):
        return sorted(article.title for article in queryset)

    def test_published_excludes_future_and_drafts(self):
        self.assertEqual(self.titles(Article.objects.published()), ['New', 'Old'])

    def test_methods_chain(self):
        queryset = Article.objects.published().recent(30).by(self.ada)
        self.assertEqual(self.titles(queryset), ['New'])

    def test_methods_are_available_on_related_managers(self):
        self.assertEqual(self.ada.articles.drafts().count(), 0)
        self.assertEqual(self.bob.articles.drafts().count(), 1)

    def test_manager_only_method(self):
        article = Article.objects.create_draft('Fresh', self.ada)
        self.assertEqual(article.status, 'draft')

    def test_publish_updates_drafts(self):
        self.assertEqual(Article.objects.drafts().publish(), 1)
        self.assertEqual(Article.objects.drafts().count(), 0)

    def test_queryset_methods_on_a_plain_manager_do_not_exist(self):
        with self.assertRaises(AttributeError):
            Article.live.drafts()

    def test_active_authors(self):
        self.assertEqual(list(Author.objects.active()), [self.ada])

    def test_first_manager_is_the_default_and_hides_rows(self):
        Snippet.objects.create(author=self.ada, text='a')
        Snippet.objects.create(author=self.ada, text='b', hidden=True)
        self.assertEqual(Snippet._default_manager.name, 'visible')
        self.assertEqual(self.ada.snippets.count(), 1)
        self.assertEqual(Snippet.objects.count(), 2)

The test test_methods_are_available_on_related_managers confirms that ada.articles.drafts() works, since the related manager is built from the default manager of Article, which has the QuerySet methods. The last test checks that the filtering default manager hides the row from ada.snippets.

$ uv run python manage.py test
Found 8 test(s).
System check identified no issues (0 silenced).
Creating test database for alias 'default'...
........
----------------------------------------------------------------------
Ran 8 tests in 0.007s

OK
Destroying test database for alias 'default'...

Source

Django documentation: Managers

Django documentation: QuerySet API reference

Django documentation: Related objects

In this article we have written a custom manager, defined chainable methods on a QuerySet, created managers with as_manager() and from_queryset(), and seen how the default manager influences related objects. Related articles are Django models, Django QuerySets, and Django query performance. The next article is about model validation and signals.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.