Managers & Custom QuerySets

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06) on Python 3.12 to 3.14, with the 5.2 LTS differences noted where they matter, and was checked on 2026-10-03. The examples ran on Django 6.1.1 and Python 3.13 with SQLite, in a throw-away project with a catalog app (the Bookshelf catalogue that grows across this section): manage.py check, makemigrations --check, 19 tests (also under python -W error) and curl against runserver all passed. Nothing here needs PostgreSQL or Redis.

Every model has at least one manager: the object behind Book.objects, through which all database queries for that model start. A manager hands out QuerySets, and a QuerySet is the lazy, chainable description of a query. Once a project has more than a handful of views, the same filters (published, by_author, with_review_stats) start to repeat; managers and custom QuerySets are the Django way to give those filters a name and a single home on the model layer. This page covers renaming and customising managers, writing reusable QuerySets and exposing them with as_manager() or from_queryset(), how Django chooses the default and base managers (and the trap hidden there), how managers are inherited and serialised into migrations, and the design question of what to put in a model, a manager or a service function.

For the QuerySet API itself (filter, annotate, lookups) see Making Queries; for the models used below see Models & Fields and Relationships.

Managers and QuerySets in the request cycle

A manager sits between your code and the ORM. A view calls Book.objects.published(); the manager builds a QuerySet bound to the Book model; nothing touches the database until the QuerySet is evaluated (iteration, list(), len(), count(), a template loop). The manager is a class attribute, so it is reached through the model class, not through instances.

flowchart LR V[View / service / command] -->|Book.objects| M[Manager] M -->|get_queryset| Q[QuerySet] Q -->|chained filters, still lazy| Q Q -->|evaluated| DB[(Database)] DB --> R[Model instances]

The model, manager and QuerySet have different jobs.

Table 1. Where each kind of logic belongs
Piece Scope Typical content

Model method or property

One row ("row-level")

str, get_absolute_url, is_published, soft_delete()

Manager method

The whole table ("table-level"), or something that is not a QuerySet

create_user(), bulk imports, factories, get_by_natural_key()

QuerySet method

A filter or annotation that must stay chainable

published(), by_author(), with_review_stats()

Service function

A use case that touches several models, transactions or external systems

publish_book(), place_order()

This is the rule the official page states for extra methods: table-level functionality belongs on a manager, row-level functionality on model methods.

Source: Managers.

Because the manager is a class attribute, it is not reachable from an instance, and it is not available on an abstract model.

book = Book.objects.create(title="X")
book.objects
# AttributeError: Manager isn't accessible via Book instances

class SoftDeleteBase(models.Model):          # abstract = True in Meta
    ...
SoftDeleteBase.objects
# AttributeError: Manager isn't available; SoftDeleteBase is abstract

The models used on this page

The examples share one small catalogue: authors, books with a status, and reviews.

from django.db import models
from django.utils import timezone


class Author(models.Model):
    name = models.CharField(max_length=100)

    def __str__(self):
        return self.name


class Book(models.Model):
    class Status(models.TextChoices):
        DRAFT = "D", "Draft"
        PUBLISHED = "P", "Published"

    title = models.CharField(max_length=200)
    status = models.CharField(max_length=1, choices=Status, default=Status.DRAFT)
    published_on = models.DateField(null=True, blank=True)
    authors = models.ManyToManyField(Author, related_name="books", blank=True)

    def __str__(self):
        return self.title


class Review(models.Model):
    book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="reviews")
    rating = models.PositiveSmallIntegerField()
    body = models.TextField(blank=True)

Source: Models.

The Book class above has no manager declared, so Django adds one called objects automatically (a plain django.db.models.Manager). The rest of the page replaces it with progressively richer managers.

Naming a manager

Django adds objects only when you declare no manager at all. You can choose any name, which is useful when objects clashes with a field name or when several managers coexist. Declaring any manager suppresses the automatic objects, so the old name stops working unless you declare it yourself.

class Person(models.Model):
    # ...
    people = models.Manager()

Person.objects      # AttributeError
Person.people.all() # works

Source: Manager names.

The managers of a model are listed in Model._meta.managers, and each manager remembers the attribute name it was assigned to in manager.name (the examples below use this to inspect the default manager).

Custom managers

There are two reasons to write a manager class: to add extra methods, and to change the initial QuerySet the manager returns.

Adding manager methods

A manager method is a good home for table-level helpers that are not QuerySet filters, such as creating several rows at once. self.model is the model class the manager is attached to, which keeps the manager reusable.

class BookManager(models.Manager):
    def manager_only_import(self, rows):
        books = [self.model(title=t, status=self.model.Status.DRAFT) for t in rows]
        return self.bulk_create(books)

A manager method may return anything, not only a QuerySet. Methods that return QuerySets, however, cannot be chained further with other custom filters, which is why the next sections move the filters onto the QuerySet.

Changing the initial QuerySet

Override get_queryset() to change the rows a manager starts from. Always start from super().get_queryset() so that using() and the other state is preserved. The following model keeps the plain manager first and adds one that only returns published editions.

class PublishedEditionManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(status="P")


class Edition(models.Model):
    book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="editions")
    status = models.CharField(max_length=1, default="D")

    objects = models.Manager()               # first declared: the default manager
    published = PublishedEditionManager()

In the test project this gave Edition.published.count() == 1 and Edition.objects.count() == 2 for one published and one draft edition. You can attach as many managers as you like; each one is a named, non-repetitive filter (Person.authors, Person.editors). Declaring objects = models.Manager() first, as above, keeps the unfiltered manager as the default (see The default manager and the base manager).

Custom QuerySets

A QuerySet subclass holds the chainable, reusable filters. Each method returns a new QuerySet, so calls compose in any order and the whole chain still runs as a single SQL query.

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


class BookQuerySet(models.QuerySet):
    def published(self):
        return self.filter(
            status=Book.Status.PUBLISHED,
            published_on__lte=timezone.now().date(),
        )

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

    def by_author(self, author):
        return self.filter(authors=author)

    def search(self, term):
        return self.filter(
            Q(title__icontains=term) | Q(authors__name__icontains=term)
        ).distinct()

    def with_review_stats(self):
        return self.annotate(
            review_count=Count("reviews", distinct=True),
            average_rating=Avg("reviews__rating"),
        )

Source: Calling custom QuerySet methods from the manager; the annotations are covered in Aggregation.

A published() method that reads the clock is the usual way to express "visible now". It is evaluated when the method is called, not when the class is defined, so the filter is always current. Keep such methods free of the request: they receive plain arguments, which makes them easy to test.

To make these methods available as Book.objects.published(), the manager has to expose them. The long way is to repeat every method on the manager and delegate to get_queryset().

class PersonQuerySet(models.QuerySet):
    def authors(self):
        return self.filter(role="A")

    def editors(self):
        return self.filter(role="E")


class PersonManager(models.Manager):
    def get_queryset(self):
        return PersonQuerySet(self.model, using=self._db)

    def authors(self):
        return self.get_queryset().authors()

    def editors(self):
        return self.get_queryset().editors()

That duplication is what as_manager() and from_queryset() remove.

as_manager()

QuerySet.as_manager() creates a manager that is a copy of the custom QuerySet’s methods. It is the shortest option and the right default when the manager needs nothing beyond QuerySet methods.

class ArticleQuerySet(models.QuerySet):
    def recent(self):
        return self.order_by("-created")[:5]

    def tagged(self, tag):
        return self.filter(tag=tag)


class Article(models.Model):
    tag = models.CharField(max_length=20)
    created = models.DateTimeField(auto_now_add=True)

    objects = ArticleQuerySet.as_manager()
>>> Article.objects.tagged("a").recent()      # manager method, then a QuerySet method
>>> type(Article.objects)
<class 'django.db.models.manager.ManagerFromArticleQuerySet'>
>>> hasattr(Article.objects, "delete")        # delete() is intentionally not copied
False

delete() is deliberately not copied to the manager, so Article.objects.delete() cannot wipe a table by accident; you write Article.objects.all().delete().

Which methods are copied

The copy follows four rules:

  • public methods are copied by default;

  • private methods (leading underscore) are not copied;

  • a method with queryset_only = False is always copied;

  • a method with queryset_only = True is never copied.

class BookQuerySet(models.QuerySet):
    def public_method(self):          # on Manager and QuerySet
        ...

    def _private_method(self):        # QuerySet only
        ...

    def raw_ids(self):                # public, but opted out: QuerySet only
        return list(self.values_list("pk", flat=True))

    raw_ids.queryset_only = True

    def _opted_in_private(self):      # private, but opted in: on both
        ...

    _opted_in_private.queryset_only = False

In the test project, hasattr(Book.objects.all(), "raw_ids") was True while hasattr(Book.objects, "raw_ids") was False, and the private helper was reachable on the QuerySet only.

from_queryset()

Manager.from_queryset(QuerySetClass) returns a subclass of a manager class with a copy of the QuerySet’s methods. Use it when you need both a custom manager (with manager-only methods) and a custom QuerySet, which is the final shape of Book.

class BookManager(models.Manager):
    def manager_only_import(self, rows):
        books = [self.model(title=t, status=self.model.Status.DRAFT) for t in rows]
        return self.bulk_create(books)


class Book(models.Model):
    # fields as above ...
    objects = BookManager.from_queryset(BookQuerySet)()
>>> Book.objects.published().by_author(ada).with_review_stats()   # chainable
>>> Book.objects.manager_only_import(["x", "y"])                   # manager only
>>> hasattr(Book.objects.all(), "manager_only_import")
False
>>> type(Book.objects).__mro__[:3]
(BookManagerFromBookQuerySet, BookManager, Manager)

You can store the generated class in a variable and instantiate it later:

BookManagerFromQuerySet = BookManager.from_queryset(BookQuerySet)

class Book(models.Model):
    objects = BookManagerFromQuerySet()

Source: from_queryset().

The published() chain compiled to one statement in the test (assertNumQueries(1) around Book.objects.published().with_review_stats().by_author(ada)), and the QuerySet returned by Book.objects.all() is a BookQuerySet, so the custom methods survive all(), filter(), exclude() and slicing.

Choosing between the three styles

Table 2. Choosing a manager style
Need Use Why

Only chainable filters

MyQuerySet.as_manager()

One class, no duplication

Chainable filters plus table-level helpers

MyManager.from_queryset(MyQuerySet)()

Manager-only methods stay off the QuerySet

A permanent restriction on the rows

A manager with a get_queryset() override

Applies to every call from that manager (see the soft-delete pattern below)

A different base query for one view only

A QuerySet method, not a manager

Keeps the restriction explicit at the call site

Custom QuerySets in use

Custom QuerySet methods work everywhere the manager does, including related managers: reverse foreign-key and many-to-many managers are built on the default manager, so they expose the same methods.

ada.books.drafts()            # many-to-many related manager
ada.books.published().count()
book.reviews.count()          # reverse foreign key related manager

The view layer stays thin. A function view that lists published books with their review statistics is three lines.

from django.http import JsonResponse
from django.shortcuts import get_object_or_404

from .models import Book


def book_list(request):
    books = Book.objects.published().with_review_stats().order_by("title")
    return JsonResponse({"books": [
        {"title": b.title, "reviews": b.review_count, "rating": b.average_rating}
        for b in books
    ]})


def book_detail(request, pk):
    book = get_object_or_404(Book.objects.published(), pk=pk)
    return JsonResponse({"title": book.title})
$ curl -s localhost:8000/catalog/books/
{"books": [{"title": "Live", "reviews": 1, "rating": 5.0}]}
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/catalog/books/9999/
404

Passing a QuerySet to get_object_or_404() is what makes the draft invisible to the detail view: a draft book answered 404 in the tests exactly like a missing one.

Testing a custom QuerySet

Because the methods are plain Python on a QuerySet, they are tested with ordinary TestCase data and assertNumQueries.

class BookQuerySetTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.ada = Author.objects.create(name="Ada")
        today = timezone.now().date()
        cls.published = Book.objects.create(title="Pub One", status="P", published_on=today)
        cls.draft = Book.objects.create(title="Draft Two")
        cls.published.authors.add(cls.ada)
        cls.draft.authors.add(cls.ada)
        Review.objects.create(book=cls.published, rating=5)
        Review.objects.create(book=cls.published, rating=3)

    def test_chaining(self):
        self.assertEqual(list(Book.objects.published()), [self.published])
        self.assertEqual(Book.objects.published().by_author(self.ada).count(), 1)

    def test_stats(self):
        book = Book.objects.with_review_stats().get(pk=self.published.pk)
        self.assertEqual((book.review_count, book.average_rating), (2, 4.0))

    def test_single_query(self):
        with self.assertNumQueries(1):
            list(Book.objects.published().with_review_stats().by_author(self.ada))

Source: Testing tools.

The default manager and the base manager

Django treats two managers specially, and getting them wrong causes data to "disappear".

  • The default manager (Model._default_manager) is the first manager declared on the model, in the order the class defines them, unless Meta.default_manager_name names another. Several parts of Django use it exclusively for that model, including dumpdata, the related managers (ada.books, book.reviews) and generic code that has to handle an unknown model.

  • The base manager (Model._base_manager) is used when Django reads a related object through a forward relation (comment.ticket). It is a plain django.db.models.Manager unless Meta.base_manager_name names another.

>>> Book._default_manager is Book.objects
True
>>> type(Book._base_manager).__name__
'Manager'

The pitfall: a filtering manager declared first

If the first manager filters rows out, the default manager hides them everywhere Django uses it.

class OpenTicketManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(archived=False)


class Ticket(models.Model):
    subject = models.CharField(max_length=50)
    archived = models.BooleanField(default=False)

    open_tickets = OpenTicketManager()   # first declared: the DEFAULT manager
    everything = models.Manager()


class Comment(models.Model):
    ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="comments")
    text = models.CharField(max_length=50)

With one archived ticket and one comment on it, the tests showed:

ticket = Ticket.everything.create(subject="s", archived=True)
Comment.objects.create(ticket=ticket, text="c")

Ticket._default_manager.count()                  # 0: the archived row is hidden
Ticket._default_manager.get(pk=ticket.pk)        # raises Ticket.DoesNotExist
Comment.objects.get().ticket                     # works: forward access uses the base manager
Comment.objects.filter(ticket__subject="s")      # one row: joins do not use managers
call_command("dumpdata", "catalog.ticket")       # [] : dumpdata used the default manager

The last line is the serious one: a backup made with dumpdata silently omits every archived ticket. Two safe designs:

  1. Declare an unfiltered manager first (objects = models.Manager()) and add the filtering manager after it, as with Edition above and Note below.

  2. Or keep the filtering manager first but name the unfiltered one with Meta.default_manager_name.

class Invoice(models.Model):
    number = models.CharField(max_length=20)
    archived = models.BooleanField(default=False)

    open_invoices = OpenTicketManager()   # declared first, but not the default
    objects = models.Manager()

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

In the test project Invoice._default_manager is Invoice.objects while Invoice.open_invoices.count() was 0 for an archived row. The generated migration records the Meta change (~ Change Meta options on invoice).

The base manager must not filter

The base manager is how Django follows a foreign key. If it hid rows, comment.ticket would fail for any comment whose ticket the manager excludes. The official guidance is therefore: never override get_queryset() in a manager used as the base manager to filter away results, or Django returns incomplete results. Base managers are not used for queries that span a relation (Comment.objects.filter(ticketsubjectstartswith="s") includes comments of excluded tickets) nor for one-to-many and many-to-many access, which use the default manager.

Soft delete with two managers

"Soft delete" (setting a timestamp instead of deleting the row) is the textbook use of a filtering manager, and it follows the rule above: the unfiltered manager is first.

class AliveManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset().filter(deleted_at__isnull=True)


class Note(models.Model):
    text = models.CharField(max_length=100)
    deleted_at = models.DateTimeField(null=True, blank=True)

    objects = models.Manager()   # default and base: sees everything
    alive = AliveManager()

    def soft_delete(self):
        self.deleted_at = timezone.now()
        self.save(update_fields=["deleted_at"])
note = Note.objects.create(text="hi")
note.soft_delete()
Note.alive.count()     # 0
Note.objects.count()   # 1

A soft-delete scheme has costs the manager does not remove: unique constraints still see the deleted rows, cascades do not fire, and every query that must exclude deleted rows has to use alive. Prefer it only when the business needs the history, and see Constraints, Indexes & Composite Keys for conditional unique constraints that exclude deleted rows.

Managers and model inheritance

Django follows three rules.

  1. Managers from base classes are always inherited, using Python’s normal resolution order (names on the child override the first parent, and so on).

  2. If no manager is declared on the model or its parents, Django adds objects.

  3. The default manager is the one named by Meta.default_manager_name, otherwise the first manager declared on the model, otherwise the default manager of the first parent model.

That makes abstract base classes a convenient way to install the same managers on many models.

class SoftDeleteBase(models.Model):
    deleted_at = models.DateTimeField(null=True, blank=True)

    objects = models.Manager()
    alive = AliveManager()

    class Meta:
        abstract = True


class Tag(SoftDeleteBase):
    name = models.CharField(max_length=30)
>>> [m.name for m in Tag._meta.managers]
['objects', 'alive']
>>> Tag._default_manager.name
'objects'

Managers belong to collections of concrete rows, so you cannot call them on the abstract class itself (SoftDeleteBase.objects raises AttributeError, as shown earlier). Put behaviour that applies to the abstract model in a classmethod or staticmethod. To keep the inherited default but add managers in a child, put the extra managers in a second abstract base that comes after the first in the bases list, because declaring a manager on the child would otherwise become the new default. See Model Inheritance & the Meta API for the inheritance styles themselves.

Managers in migrations

By default migrations do not serialise managers, so apps.get_model() in a RunPython returns a model with a plain manager. Set use_in_migrations = True to have the manager written into the migration and available to data migrations, and give it a get_by_natural_key() if serialisation by natural key needs it.

class SlugManager(models.Manager):
    use_in_migrations = True

    def get_by_natural_key(self, slug):
        return self.get(slug=slug)


class Genre(models.Model):
    slug = models.SlugField(unique=True)

    objects = SlugManager()

    def natural_key(self):
        return (self.slug,)

makemigrations then wrote the manager into the migration:

managers=[
    ("objects", catalog.models.SlugManager()),
],

The manager class must be importable by the migration. A manager returned by from_queryset() is generated dynamically, so inherit from the generated class and set the attribute on the subclass.

class ShelfQuerySet(models.QuerySet):
    def nonempty(self):
        return self.exclude(label="")


class ShelfManager(models.Manager.from_queryset(ShelfQuerySet)):
    use_in_migrations = True


class Shelf(models.Model):
    label = models.CharField(max_length=20, blank=True)

    objects = ShelfManager()

That produced ("objects", catalog.models.ShelfManager()) in the migration. Two related details in the output of the earlier Ticket model: a manager that is the default but is not called objects is also recorded in the migration, and, because it is not marked use_in_migrations, as a plain django.db.models.manager.Manager() (("open_tickets", django.db.models.manager.Manager())). See Migrations for data migrations and the notes about historical models.

Async

QuerySets and managers have async counterparts with an a prefix (aget, acreate, acount, aexists, abulk_create and so on), plus async for iteration over a QuerySet. They work unchanged on custom QuerySet methods, because those return ordinary QuerySets.

class AsyncTests(TestCase):
    async def test_async_methods_on_custom_queryset(self):
        await Book.objects.acreate(title="a")
        self.assertEqual(await Book.objects.drafts().acount(), 1)
        self.assertEqual([b.title async for b in Book.objects.drafts()], ["a"])

Do not call a blocking, evaluating manager method from async code; write the custom method so it returns a QuerySet (which stays lazy) and let the caller await the evaluating method. A custom manager method that performs queries itself (like manager_only_import) is synchronous, and in an async view it has to be wrapped with sync_to_async() or reimplemented with the a methods.

Fat models versus service functions

Where does business logic go? There are two common answers and both have a place.

Fat models, thin views. Put behaviour on the model (row-level), the QuerySet (reusable filters) and the manager (table-level). The views, admin, management commands and API serialisers then all share the same vocabulary: Book.objects.published().by_author(ada). This is the Django style, and the one-to-one mapping between a domain concept and a queryset method makes the code readable and testable. The books of the Django 1.x and 2.x era recommended it, usually with mixins that add helpers to models.

Service functions. When a use case spans several models, needs a transaction, sends an email or calls an external system, a plain function (or a small module such as catalog/services.py) is clearer than hiding the orchestration in a model’s save() or in a signal handler.

from django.db import transaction
from django.utils import timezone

from .models import Book, Review


@transaction.atomic
def publish_book(book: Book) -> Book:
    """Orchestration that touches several models belongs in a service function."""
    if book.authors.count() == 0:
        raise ValueError("A book needs at least one author before it is published")
    book.status = Book.Status.PUBLISHED
    book.published_on = timezone.now().date()
    book.save(update_fields=["status", "published_on"])
    return book


def add_review(book: Book, rating: int, body: str = "") -> Review:
    return Review.objects.create(book=book, rating=rating, body=body)

In the tests publish_book() raised ValueError for a book without authors, and after a successful call the book appeared in Book.objects.published().

A workable division:

Table 3. A practical rule of thumb
If the code …​ Put it in

reads or filters rows of one model

a QuerySet method

reads or acts on one instance

a model method or property

builds or loads rows in bulk for one model

a manager method

changes several models, needs atomic, or has side effects (email, tasks, HTTP calls)

a service function

only formats data for the page

a template tag, filter or serialiser

Keep services out of the QuerySet (a QuerySet method should return a QuerySet), keep side effects out of save(), and prefer an explicit call to a service over a signal receiver when a human has to understand the flow. For the layering vocabulary (entities, use cases, repositories) see the architecture pages under Backend Architecture; Django’s ORM is an active record, so a repository layer on top of managers is usually unnecessary.

Pitfalls

  • A filtering manager declared first becomes the default manager, and dumpdata, related managers and generic code then see only the filtered rows. Declare an unfiltered manager first, or set Meta.default_manager_name.

  • Never filter in a base manager: Meta.base_manager_name must name a manager that returns every row.

  • Declaring any manager removes the automatic objects. Add objects = models.Manager() back if other code uses it.

  • Manager methods that return QuerySets do not chain with QuerySet methods unless the QuerySet methods are also exposed on the manager; use as_manager() or from_queryset() instead of copying methods by hand.

  • A manager must be copyable with copy.copy() because Django makes shallow copies during some queries. Overriding getattr or other methods that control the manager’s state can break it (the test project confirmed copy.copy(Book.objects) works).

  • Managers are not available on instances or on abstract models. Use a classmethod for logic on an abstract model.

  • delete() is not copied to a manager by as_manager() and from_queryset().

  • Related managers use the default manager, so a filtering default manager also changes book.editions.all().

  • Do not put side effects in manager methods called from templates. In templates, methods that change data should set alters_data = True; Django does this for create(), bulk_create() and similar built-in methods since 5.2.

  • get_queryset() overrides are not a security boundary. Joins and _base_manager bypass them, so authorisation must be enforced in views, permissions or queries that name the user.

Where the books are outdated

  • use_for_related_fields. Older recipes set use_for_related_fields = True on a manager so Django would use it for related access. It was deprecated in Django 1.10 in favour of Meta.base_manager_name and removed in 2.0. The Django 2.0 release also made manager inheritance follow MRO rules and removed the Meta.manager_inheritance_from_future opt-in, so older advice on abstract-class manager ordering may not match what Django does today.

  • Synchronous-only examples. Books written for Django 1.11 to 2.x predate the async ORM (4.1); QuerySets and managers now have aget, acount, async for and other a methods.

  • as_manager() and from_queryset() are not new, but books from the Django 1.x era often still show the method-duplication pattern. QuerySet.as_manager() appeared in 1.7.

  • choices as a list of tuples. Examples show choices=[("A", "Author"), …​]; since Django 5.0 a mapping, a callable or an enumeration type can be used directly (choices=Status), as in the examples above.

  • Fat-model mixins. Older advice to pile helpers into model mixins works, but behaviour that spans several models or has side effects is easier to follow as an explicit service function (see above) than hidden in save() or delete() overrides.

What changed recently

Table 4. Manager and QuerySet changes by version
Version Change

1.7

QuerySet.as_manager() creates a manager from a QuerySet class.

1.10

Meta.default_manager_name and Meta.base_manager_name control _default_manager and _base_manager; use_for_related_fields is deprecated.

2.0

use_for_related_fields is removed and manager inheritance follows the MRO.

4.1

Asynchronous QuerySet methods (aget(), acreate() and so on).

5.0

choices accepts mappings, callables and enumeration types without .choices.

5.2

Data-modifying methods such as create() and bulk_create() (and their async versions) carry alters_data=True.

6.1

New QuerySet.fetch_mode() (FETCH_ONE, FETCH_PEERS, FETCH_RAISE; see Fetch modes) and the QuerySet.totally_ordered property, both inherited by custom QuerySet classes; in_bulk() can follow values() and values_list(); select_related() with no arguments and values_list(flat=True) with no field name are deprecated. Managers themselves are unchanged.

The 1.7, 1.10 and 2.0 rows are from those release notes, the 4.1 and 5.2 rows from the 4.1 and 5.2 notes (async ORM, alters_data), the 5.0 row from the 5.0 notes, and the 6.1 row from the Django 6.1 release notes. The 3.x, 4.0, 4.2, 5.1 and 6.0 notes mention managers only for UserManager, BaseUserManager and LogEntryManager.