Filtering Querysets in Django: Keep Database Logic Out of Templates

Published · Updated · Programming

cover image for article

Django templates are deliberately bad at being a query language. That is a feature, not a missing capability. If a template decides which rows to load, applies business rules, and then walks relationships one item at a time, the result is difficult to test and surprisingly easy to turn into an N+1 query problem.

The useful question is not, "How do I filter a queryset in a Django template?" It is, "Which layer should own this decision, and how do I give the template exactly what it needs to render?" For most applications, the answer is a view, a small query function, or a model manager. The template should receive prepared data and make presentation decisions only.

Start With a Queryset That States the Page's Intent

Suppose a product listing should show currently available products. Put that rule where a reader of the view can see it immediately:

# views.py
from django.shortcuts import render

from .models import Product


def product_list(request):
    products = Product.objects.filter(is_available=True).order_by("name")
    return render(request, "catalog/product_list.html", {"products": products})

That is not just tidier than calling .filter() from a custom template filter. It also gives you one obvious place to add authorization, ordering, caching, eager loading, and tests. The template gets a collection whose meaning is already clear:

{% for product in products %}
  <li>{{ product.name }}</li>
{% empty %}
  <li>No products are available right now.</li>
{% endfor %}

For a rule used by more than one page, move the rule closer to the model. A custom QuerySet keeps chaining available and avoids an ever-growing pile of one-off manager methods:

# models.py
from django.db import models


class ProductQuerySet(models.QuerySet):
    def available(self):
        return self.filter(is_available=True)


class Product(models.Model):
    name = models.CharField(max_length=200)
    is_available = models.BooleanField(default=True)
    category = models.ForeignKey("Category", on_delete=models.PROTECT)

    objects = ProductQuerySet.as_manager()

Now a view can say Product.objects.available() without teaching the template anything about persistence. This is also a good boundary for rules such as "visible to this account" or "not archived." Keep request-specific policy out of the model when it needs the current user or organization; a query function or service layer is usually clearer in that case.

Load Relationships Before Rendering

The most expensive template bugs often look harmless. This loop can issue one extra query per product if category has not been loaded:

{% for product in products %}
  <li>{{ product.name }}{{ product.category.name }}</li>
{% endfor %}

Use select_related() for a foreign key or one-to-one relationship that the page will display:

products = (
    Product.objects.available()
    .select_related("category")
    .order_by("name")
)

Use prefetch_related() for many-to-many and reverse relationships. For example, if every product card shows its labels:

products = (
    Product.objects.available()
    .select_related("category")
    .prefetch_related("labels")
    .order_by("name")
)

These are performance decisions, so they belong next to the query rather than inside a template tag. Use Django's query logging or a development tool to check the count with realistic page sizes. Premature eager loading can waste memory, but rendering a list without noticing repeated relationship lookups is usually worse.

Treat User-Supplied Filters as Input, Not Template Logic

Search and filtering pages often receive their state through query parameters: ?category=hardware&q=keyboard. A GET form makes the state linkable and refreshable. A Django form validates it before it reaches the ORM.

# forms.py
from django import forms


class ProductFilterForm(forms.Form):
    q = forms.CharField(required=False, max_length=100)
    category = forms.IntegerField(required=False, min_value=1)
# views.py
from django.core.paginator import Paginator
from django.shortcuts import render

from .forms import ProductFilterForm
from .models import Product


def product_list(request):
    form = ProductFilterForm(request.GET)
    products = Product.objects.available().select_related("category")

    if form.is_valid():
        query = form.cleaned_data["q"]
        category_id = form.cleaned_data["category"]
        if query:
            products = products.filter(name__icontains=query)
        if category_id:
            products = products.filter(category_id=category_id)

    page = Paginator(products.order_by("name"), 25).get_page(request.GET.get("page"))
    return render(request, "catalog/product_list.html", {"form": form, "page": page})

The form does not make a search page magically safe or fast. It does make the accepted inputs explicit, and it keeps invalid values from silently becoming a different query. For more involved filtering, a dedicated filter object can compose the query a step at a time and be tested without rendering HTML.

Paginate the Queryset, Not a Python List

Paginator works best with a queryset because Django can add LIMIT and OFFSET rather than loading every matching row into Python. Do not turn a large queryset into list(products) just to make template filtering convenient.

The template then has a compact, predictable job:

<form method="get">
  {{ form.q }}
  {{ form.category }}
  <button type="submit">Filter</button>
</form>

{% for product in page %}
  <article>
    <h2>{{ product.name }}</h2>
    <p>{{ product.category.name }}</p>
  </article>
{% empty %}
  <p>No products matched those filters.</p>
{% endfor %}

{% if page.has_other_pages %}
  <nav aria-label="Product pages">
    {% if page.has_previous %}
      <a href="?page={{ page.previous_page_number }}">Newer results</a>
    {% endif %}
    <span>Page {{ page.number }} of {{ page.paginator.num_pages }}</span>
    {% if page.has_next %}
      <a href="?page={{ page.next_page_number }}">Older results</a>
    {% endif %}
  </nav>
{% endif %}

In a production filter UI, preserve the other query parameters when building pagination links. A small template tag for URL manipulation is fine because it formats request state; it should not decide which database rows exist.

What Template Filters Are Actually Good For

Template filters are useful for presentation-only transformations: formatting a date, choosing a human-friendly label, or rendering a value consistently. They should be deterministic and should not hit the database.

# templatetags/product_display.py
from django import template

register = template.Library()


@register.filter
def availability_label(is_available):
    return "In stock" if is_available else "Unavailable"
{% load product_display %}
<span>{{ product.is_available|availability_label }}</span>

A filter named available_products that calls products.filter(...) is tempting, but it hides query construction in a rendering layer. It becomes especially risky when another developer reuses it inside a loop or assumes the input has already been evaluated. The same concern applies to template tags that retrieve models by default. Make database access obvious in Python code that can be profiled and covered by tests.

A Practical Ownership Rule

Use this simple division of responsibilities:

  • Model queryset or manager: reusable, data-centric rules that do not need the request.
  • View or query/service function: request-specific filters, authorization, ordering, eager loading, and pagination.
  • Form: validation and normalization of user-controlled filter values.
  • Template: loops, empty states, and display formatting.

That division makes a Django page easier to reason about when the first version becomes the fifth. It also makes performance work concrete: inspect the query in one place, add select_related() or prefetch_related() where it belongs, and verify the result with a test. For another example of keeping Django behavior explicit rather than relying on template magic, see how to limit a Django ForeignKey to staff users.

Filtering belongs where the page's data contract is built. Once the template gets a small, already-prepared queryset or page object, it can do what templates are good at: make the result understandable to a human.

For more practical software-engineering guidance, return to Slaptijack.

Slaptijack's Koding Kraken