· 8 min read · Engineering
Advanced Django — Part 2: Custom querysets and managers

This is Part 2 of the Advanced Django series. Part 1 covered the model layer — the BaseModel that every table inherits from and the small conventions that go with it.
Part 2 is about custom querysets and managers. I want to open honestly: I don't reach for these on every project. My most common multi-tenancy pattern is to resolve the tenant in the view (or in middleware) and pass it down to a selector function — not to bake it into a queryset method. But custom querysets are a real Django pattern with real strengths, and there are cases where they earn their keep. This post covers both sides: what they do, when I'd use them, and when I wouldn't.
If you never touch these and rely on objects.filter() calls in views, that's a completely valid way to write Django. I'll show what you're not using, and then honestly compare it to the pattern I use most of the time. There's also one case — tenant isolation — where I reach for a custom queryset every single time, and that's the end of this post.
Companion repo: the patterns in this series live in
ademolaidowu/django-saas-foundationon GitHub — a working Django + DRF project you can clone, run, and read alongside the posts.
What a custom queryset actually is
Django's Manager (the objects attribute) is what you call .filter(), .all(), .get() on. Each of those returns a QuerySet. The QuerySet is the chainable object — .filter().exclude().annotate().order_by() all return new QuerySets.
A custom queryset is a subclass of models.QuerySet where you add domain-specific methods that return more QuerySets. Because they all return self.filter(...) (which is another QuerySet of the same class), the methods chain.
The smallest useful example:
# apps/orders/models/order.py
from django.db import models
class OrderQuerySet(models.QuerySet["Order"]):
def unpaid(self) -> "OrderQuerySet":
return self.filter(status="PENDING")
def for_organization(self, organization) -> "OrderQuerySet":
return self.filter(organization=organization)
class OrderManager(models.Manager["Order"]):
def get_queryset(self) -> OrderQuerySet:
return OrderQuerySet(self.model, using=self._db)
class Order(BaseModel):
# ... fields ...
objects = OrderManager()Now anywhere in the codebase:
Order.objects.for_organization(org).unpaid()That's the appeal — a domain-shaped read that reads like English and doesn't scatter filter(status="PENDING", organization=org) across every view.
The manager sits on top
Two roles to keep straight:
- QuerySet — chainable, returns more QuerySets.
- Manager — the model-level entry point (
Model.objects). You never call.filter()directly on a Manager; you call it on the QuerySet the Manager returns.
The pattern above uses models.Manager and overrides get_queryset() to return your custom class. There's also a shortcut — objects = OrderQuerySet.as_manager() — that skips the Manager subclass entirely and gets you the same result. I usually write the explicit two-class version because it leaves room for manager-level methods that aren't chainable (create_from_signup(...) on a user manager, that kind of thing).
If you inherit from the BaseModel in Part 1, you already get objects and records managers from the base class. Adding a custom queryset means overriding one of those on the concrete model — which is easy, but is an extra thing to remember.
When custom querysets earn their keep
I reach for a custom queryset in three specific cases.
The same filter shows up in three or more places. If you have Order.objects.filter(status="PENDING", refunded=False) in every view, admin action and Celery task, that's not a filter anymore — that's a domain concept called "unpaid". Give it a name and stop re-typing the definition.
The filter is easy to get wrong. Things like: "orders that count toward the tenant's monthly quota" isn't status="FULFILLED" — it's status IN (...) AND deleted_at IS NULL AND created_at >= <month_start>. Every time someone rewrites that inline, they'll forget one of the conditions. Order.objects.quota_counting() is one method with one place to fix a bug.
You're annotating or joining and it's fiddly. If a queryset needs .select_related("customer__organization").prefetch_related("line_items").annotate(total=Sum(...)), that's not readable at every call site. Wrap it: Order.objects.with_totals() or Order.objects.detailed() returns the pre-optimized queryset, and callers stay simple.
The common thread across all three: the domain has a name for a shape you keep needing. A method with that name reads better than the raw ORM code. And when the shape changes — a new status enum value, a new soft-delete field — you edit one method instead of grepping every view.
When they don't (and what I actually do)
The trap I've watched teams fall into is building a custom queryset infrastructure "just in case" — every model gets its own QuerySet, Manager, and two subclasses of each — and then filling those methods with things that only get called once. That's ceremony for its own sake.
Here's where I don't use custom querysets:
When my multi-tenancy is already scoped elsewhere. In most of my projects, tenant resolution happens in middleware — it puts request.organization on the request object based on the authenticated user's active org. Views pick it up and pass it down. A selector function does the actual filter:
# apps/orders/selectors.py
from django.db.models import QuerySet
from orders.models import Order
def list_orders_for_organization(*, organization) -> QuerySet[Order]:
return Order.records.filter(organization=organization).select_related("customer")# apps/orders/views.py
class OrderListView(ListAPIView):
def get_queryset(self):
return list_orders_for_organization(organization=self.request.organization)That does the same job as a custom for_organization() queryset method, and it does it with two functions and no queryset subclass. Selectors are the topic of Part 3, so I won't go deep here — but this is the shape I default to on most projects.
When the filter is used once. If a single view needs a specific slice — "orders for this customer that failed payment in the last 24 hours, needed for one refund report" — an inline .filter(...) in the selector is clearer than adding a method to a queryset class you'd never use again.
When the codebase is small enough that you'd know every call site. On a project with 20 models and one or two views per model, the DRY-across-the-codebase argument for custom querysets doesn't apply — there's nothing to be DRY across. Start simple; refactor when a pattern actually repeats.
Custom querysets vs view/selector scoping — side by side
Let me put both approaches next to each other on the same task: list open orders for the tenant, sorted by newest first.
Custom queryset version:
# models
class OrderQuerySet(models.QuerySet["Order"]):
def for_organization(self, organization) -> "OrderQuerySet":
return self.filter(organization=organization)
def open(self) -> "OrderQuerySet":
return self.filter(status__in=["PENDING", "PARTIALLY_PAID"])
# view
class OpenOrdersView(ListAPIView):
def get_queryset(self):
return (
Order.objects
.for_organization(self.request.organization)
.open()
.order_by("-created_at")
)Selector version (what I use):
# selectors
def list_open_orders(*, organization) -> QuerySet[Order]:
return (
Order.records
.filter(organization=organization)
.filter(status__in=["PENDING", "PARTIALLY_PAID"])
.order_by("-created_at")
)
# view
class OpenOrdersView(ListAPIView):
def get_queryset(self):
return list_open_orders(organization=self.request.organization)Both work. Pick based on where the complexity actually lives:
- If the same slice ("open orders for this org") shows up in an API endpoint, an admin action, a Celery task, and a report — the queryset method wins because there's one name and one definition to change.
- If it shows up in one view and one background job — the selector wins because there's less machinery around it.
Neither is wrong. They're two ways of naming the same query, and the honest question is "how many callers, and how likely is the shape to change?"
The exception that proves the rule: TenantScopedQuerySet
Everything above is why I usually don't reach for custom querysets. The exception is when the queryset itself has to enforce an invariant that the view can't be trusted to remember. The clearest example of this — the one I now put on every tenant-scoped model — is a queryset that refuses to evaluate without an explicit organization scope.
Here's the shape, trimmed down to the load-bearing parts. The full file is in apps/core/models/tenancy.py.
# apps/core/models/tenancy.py (abbreviated)
import warnings
from core.exceptions import UnscopedQueryError
from core.models.base import BaseQuerySet
_SCOPED_ATTR = "_tenant_scoped"
class TenantScopedQuerySet(BaseQuerySet):
"""QuerySet that refuses to evaluate without an explicit tenant scope."""
def for_organization(self, organization) -> "TenantScopedQuerySet":
if organization is None:
raise UnscopedQueryError("for_organization(None) is not allowed.")
qs = self.filter(organization=organization)
setattr(qs, _SCOPED_ATTR, True)
return qs
def unsafe_all(self) -> "TenantScopedQuerySet":
"""Escape hatch for admin, migrations, data-fix scripts."""
warnings.warn(
f"unsafe_all() called on {self.model.__name__} — tenant guard bypassed.",
RuntimeWarning,
stacklevel=2,
)
qs = self._chain()
setattr(qs, _SCOPED_ATTR, True)
return qs
def _check_scoped(self) -> None:
if getattr(self, _SCOPED_ATTR, False):
return
raise UnscopedQueryError(
f"Unscoped query on {self.model.__name__}. "
"Call .for_organization(org) or .unsafe_all() first."
)
def _fetch_all(self) -> None:
self._check_scoped()
super()._fetch_all()
# Same `_check_scoped()` guard on count(), exists(), get(), first(),
# last(), update(), delete() — every evaluation entry point.Two moving pieces. for_organization(org) is the only sanctioned way to make the queryset willing to run — it adds the organization= filter and flips a sentinel attribute. The guard hooks (_fetch_all, count, exists, get, first, last, update, delete) all call _check_scoped() before delegating; if the sentinel isn't set, you get UnscopedQueryError instead of a result. _clone() and _chain() preserve the sentinel so .filter()/.exclude()/.order_by() keep working after the scope is set. There's also an unsafe_all() escape hatch for admin changelists and migrations — it logs a RuntimeWarning, so the opt-out shows up in logs.
Why this is worth the ceremony: the whole point is that the cost of forgetting isn't a slow page or an off-by-one — it's a cross-tenant data leak that quietly ships to prod. A developer who writes Project.objects.filter(status="active") on a TenantScopedModel and forgets .for_organization(org) gets an UnscopedQueryError on the next line, before the queryset returns a single row. The invariant is enforced at the queryset layer, not at the view, because views are the easiest place in the stack to forget. A TENANT_GUARD_MODE=warn setting lets you downgrade the error to a logged warning in development or during a migration — but the production default is "fail closed."
This is the one custom queryset I reach for on every project the moment multi-tenancy enters the picture. The three questions from the rule of thumb above all answer yes, and the fourth — does the shape have teeth if you forget? — is where this one goes beyond "nice to have."
Custom managers — separate but related
A custom manager isn't only about attaching a custom queryset. Managers can also carry methods that operate at the class level and don't chain — things like factories:
class UserManager(BaseUserManager["User"]):
def create_user(self, *, email, password, **extra):
user = self.model(email=self.normalize_email(email), **extra)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, *, email, password, **extra):
extra.setdefault("is_staff", True)
extra.setdefault("is_superuser", True)
return self.create_user(email=email, password=password, **extra)User.objects.create_user(...) isn't a chainable query — it's a factory. That's a manager-level method, not a queryset method. Django's own User model works this way, and it's the reason AbstractBaseUser requires you to provide a manager.
Rule of thumb: QuerySet for chainable reads. Manager for factories and object creation flows.
A rule of thumb I trust
When I catch myself starting to write a custom QuerySet class, I ask three questions:
- Is the same query shape used in more than two places? If no, inline it.
- Does the shape have a domain name? If no, it's probably a one-off — inline it.
- Would the queryset method be easier to test than the inline version? If no, don't build it.
Two yeses out of three usually means I write the queryset. One or fewer, I write it inline in a selector.
What's next in the series
Part 3 covers selectors and services — the read and write layers that sit between the ORM and everything else. This is where a lot of what could be queryset methods actually ends up in my codebases, because a selector function is often the simpler answer. I'll show the split I use, when a queryset method beats a selector (and when it's the other way around), and how services handle the write side with atomicity and idempotency baked in.
If you use custom querysets differently — or if you've been burned by a pattern I made sound better than it is — tell me. This series gets better when the choices get pushed back on.
- django
- python
- software development