DEV Community

Cover image for Stop Repeating .filter(is_active=True) Everywhere
Anas Issath
Anas Issath

Posted on Originally published at levelup.gitconnected.com

Stop Repeating .filter(is_active=True) Everywhere

Custom managers and querysets: how to name your query logic once, reuse it everywhere, and chain it like Django's built-in methods.

Look through almost any Django codebase and you'll find the same filter written over and over. Post.objects.filter(status="published") in the list view, again in the API, again in the sidebar, again in the sitemap. Or User.objects.filter(is_active=True, is_deleted=False) scattered across forty files.

It works, until someone adds a new rule ("also exclude banned users"). Now you have to find and update all forty places, you'll miss three of them, and there's a bug in production.

This is exactly the problem custom managers and querysets solve, and it's one of the most underused features in Django. It lets you take a piece of query logic ("the published posts," "the active users"), give it a name, define it once, and reuse it everywhere as Post.objects.published(). The query lives in one place. Change the rule once and the whole app updates.

This article shows you how, from the simplest version to the full chainable pattern, all current for Django 6.x and drawn from the official docs.


First, What a Manager Actually Is

You use one every single day without thinking about it: objects. When you write Post.objects.filter(...), that objects is a Manager, the entry point through which all database queries for a model begin. Every model gets a default manager called objects for free.

The manager's core job is to hand you a QuerySet to start from. In fact, Post.objects.all() is really the manager saying "here's a queryset of every Post." Everything else (.filter(), .exclude(), .order_by()) are methods on the queryset that manager returns.

So there are two layers here, and understanding the split is the whole key:

  • The Manager (objects) is the starting point.
  • The QuerySet is the chainable thing you build queries on.

Customizing them means teaching your model's objects to produce querysets that already know your business logic. Let's start simple.


The Simplest Version: A Custom QuerySet with Named Methods

The cleanest way to name query logic is to write your own QuerySet class with methods for each named filter, then attach it to your model. Say you keep filtering posts by status and author:

from django.db import models

class PostQuerySet(models.QuerySet):
    def published(self):
        return self.filter(status="published")

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

class Post(models.Model):
    status = models.CharField(max_length=20)
    author = models.ForeignKey("Author", on_delete=models.CASCADE)

    objects = PostQuerySet.as_manager()   # ← the magic line
Enter fullscreen mode Exit fullscreen mode

That one line, objects = PostQuerySet.as_manager(), turns your custom QuerySet into the model's manager. Now you can write:

Post.objects.published()                    # instead of .filter(status="published")
Post.objects.by_author(some_author)
Enter fullscreen mode Exit fullscreen mode

The published-posts rule now lives in exactly one place. If "published" later means "status is published and not scheduled for the future," you change the published() method once, and every caller across your entire codebase gets the new behavior automatically. That's the whole payoff.


The Feature That Makes It Powerful: Your Methods Chain

Here's what elevates this from "a nice helper" to "genuinely powerful." Because your custom methods live on a QuerySet and each returns self.filter(...) (another queryset), they chain, with each other and with Django's built-in methods. That's the entire reason to define them on a QuerySet rather than as loose functions:

Post.objects.published().by_author(alex).exclude(title="Draft").order_by("-created_at")
Enter fullscreen mode Exit fullscreen mode

Read that left to right: start with published posts, narrow to Alex's, drop anything titled "Draft," order by newest. Your custom published() and by_author() sit seamlessly in a chain alongside the built-in exclude() and order_by(), because they're all queryset methods returning querysets.

You've extended the ORM's vocabulary with your domain's words, and they compose exactly like the native ones.

This is why the QuerySet approach beats writing helper functions like get_published_posts(qs). A function is a dead end you can't chain, but a queryset method flows naturally into the rest of the query. Compose small, named, single-purpose methods and you can express rich queries in a way that reads almost like English:

Post.objects.published().popular().recent()
Enter fullscreen mode Exit fullscreen mode

Each piece is defined and tested once.


The Fuller Pattern: A Custom Manager (and the Trap It Avoids)

as_manager() is perfect when all you want is custom queryset methods. But sometimes you want to customize the manager itself, most commonly to change what objects returns by default.

The classic example: you've soft-deleted rows (an is_deleted flag) and you want Post.objects.all() to never include deleted posts, without every caller remembering to filter them out.

For that, you override the manager's get_queryset(), the method that produces the base queryset everything starts from:

class PostQuerySet(models.QuerySet):
    def published(self):
        return self.filter(status="published")

class PostManager(models.Manager):
    def get_queryset(self):
        # every query through this manager starts already filtered
        return PostQuerySet(self.model, using=self._db).filter(is_deleted=False)

class Post(models.Model):
    # ...
    objects = PostManager()          # excludes deleted by default
    all_objects = models.Manager()   # a second manager that sees everything
Enter fullscreen mode Exit fullscreen mode

Now Post.objects.all() silently excludes deleted rows everywhere, and if you ever do need the deleted ones, Post.all_objects gives you the unfiltered view.

A model can have multiple managers, and this "default manager hides soft-deleted rows, escape-hatch manager shows all" setup is one of the most useful patterns in real Django apps.

But there's a catch, and it's the trap people hit. If you write your published() methods on the QuerySet but define your manager the manual way above, the manager doesn't automatically get those queryset methods. Post.objects.published() would raise AttributeError, because published lives on the QuerySet, not the Manager.

You'd have to redefine each one on the manager too: tedious and error-prone.


The Best of Both: Manager.from_queryset()

Django solves that trap elegantly with Manager.from_queryset(), which builds a manager that automatically exposes all your custom QuerySet methods and lets you override get_queryset(). This is the pattern to reach for when you want both custom queryset methods and a customized base query:

class PostQuerySet(models.QuerySet):
    def published(self):
        return self.filter(status="published")

    def popular(self):
        return self.filter(view_count__gte=1000)

class PostManager(models.Manager.from_queryset(PostQuerySet)):
    def get_queryset(self):
        return super().get_queryset().filter(is_deleted=False)

class Post(models.Model):
    # ...
    objects = PostManager()
Enter fullscreen mode Exit fullscreen mode

Now you get everything:

Post.objects.all()                      # excludes deleted (from get_queryset)
Post.objects.published()                # custom queryset method, available on the manager
Post.objects.published().popular()      # chains, and still excludes deleted
Enter fullscreen mode Exit fullscreen mode

from_queryset(PostQuerySet) copies all the queryset's methods onto the manager, and overriding get_queryset() sets the base filter. You define each query rule once, on the QuerySet, and it's usable both as a chainable queryset method and directly off objects, with your default filtering always applied underneath.

This is the pattern most mature Django codebases converge on.


The One Rule That Trips People Up: Manager Methods vs QuerySet Methods

The subtlety worth internalizing:

Methods defined on the QuerySet chain. Methods you add directly to a Manager do not.

A manager method is a starting point (Post.objects.something()), but whatever it returns is where the chain begins. You can't do Post.objects.filter(...).something() if something is a manager-only method, because after .filter() you have a queryset, and the queryset doesn't have that method.

That's exactly why the modern advice is: put your reusable query logic on the QuerySet, and use as_manager() or from_queryset() to expose it on the manager. That way each method works in both positions, because it truly lives on the queryset:

  • Post.objects.published() (from the start)
  • Post.objects.filter(...).published() (mid-chain)

Defining logic directly on the manager is the older, more limited approach. The queryset-first approach is what gives you the chaining that makes this feature shine.


Why Understanding This Makes You Better at Django

The difference between a junior and a senior Django codebase is often visible in exactly this. The junior one has filter(status="published") copy-pasted across thirty files, and the senior one has Post.objects.published() defined once.

When the definition of "published" changes (and it always does), the first codebase becomes a bug hunt across thirty locations, and the second one is a one-line edit. Custom querysets turn scattered, duplicated, drift-prone query logic into named, single-source-of-truth, testable methods that read like your domain and compose like the ORM's own.

And the mechanics are simple once the two layers click:

  • The Manager (objects) is the entry point that hands you a base QuerySet.
  • Put your named query logic as methods on a custom QuerySet so it chains.
  • Use as_manager() when that's all you need.
  • Use from_queryset() (plus a get_queryset() override) when you also want to customize the default query, like hiding soft-deleted rows.

Do that, and you stop repeating yourself, you stop drifting out of sync, and your queries start reading like the business rules they actually represent: Post.objects.published().popular().recent() instead of a pile of raw filters nobody remembers the meaning of.


The 30-Second Takeaway

  • Scattering the same .filter(...) across many files is a maintenance trap. When the rule changes, you must find and fix every copy. Custom managers/querysets let you define it once, named, and reuse it everywhere.
  • objects is a Manager, the entry point that hands you a base QuerySet. Built-in .filter()/.exclude() are queryset methods. Two layers: manager = starting point, queryset = the chainable thing.
  • Simplest pattern: write a class PostQuerySet(models.QuerySet) with named methods (published(), popular()), then objects = PostQuerySet.as_manager(). Now Post.objects.published() works.
  • The payoff is chaining. Because your methods live on the QuerySet and return querysets, they compose with each other and with built-ins: Post.objects.published().by_author(x).order_by("-created_at").
  • Customize the default query (e.g. hide soft-deleted rows) by overriding get_queryset() on a Manager, and give models multiple managers (objects filtered, all_objects unfiltered).
  • Best pattern: Manager.from_queryset(PostQuerySet) + a get_queryset() override. Your custom methods are available on objects and chain, and your default filter always applies.
  • Key rule: methods on a QuerySet chain; methods added directly to a Manager don't. Put reusable logic on the QuerySet and expose it via as_manager()/from_queryset(), so it works both as a starting point and mid-chain.

Your Turn

Grep your codebase for a filter you've written more than twice: filter(is_active=True), filter(status="published"). That repetition is a custom queryset method waiting to be born. Name it once, and every one of those copies becomes a single, readable, future-proof call.

What's the most-repeated filter in your codebase? Tell me in the comments 👇


If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.

Top comments (0)