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.
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.
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.
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.
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.
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.
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.
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
List all Django tutorials.