Tags: Filtering the Catalogue by Tag

Personal Catalogue: Django & PostgreSQL

Chapter 6 · Tags: Filtering the Catalogue by Tag

Chapter 5 let items be tagged. This chapter makes those tags actually useful for the app's own real core question — not just "do I already own this?" but the narrower, equally real "what Python books do I have?" — by building a genuine filter-by-tag feature into the list page.

Filtering via a Query Parameter

Rather than a separate page per tag, this course uses one real, shareable URL pattern — /?tag=python — read directly off the query string:

# catalogue/views.py (ItemListView, updated) class ItemListView(ListView): model = Item template_name = 'catalogue/item_list.html' context_object_name = 'items' paginate_by = 25 def get_queryset(self): queryset = Item.objects.select_related('item_type').prefetch_related('tags') tag_name = self.request.GET.get('tag') if tag_name: queryset = queryset.filter(tags__name=tag_name) return queryset def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['active_tag'] = self.request.GET.get('tag', '') return context

queryset.filter(tags__name=tag_name) follows the ManyToManyField relationship directly in the ORM — no manual join, no separate query, since Django's own field-lookup syntax (tags__name) turns straight into a real SQL join against the auto-generated join table from Chapter 2.

Making Tags Clickable

A small template change turns each tag shown on the list and detail pages into a real link back into this same filter:

<!-- inside the item loop in item_list.html --> {% for tag in item.tags.all %} <a href="?tag={{ tag.name }}">#{{ tag.name }}</a> {% endfor %}

And a small banner showing the active filter, with a real way back out of it:

{% if active_tag %} <p>Filtered by: #{{ active_tag }} <a href="{% url 'item_list' %}">(clear)</a></p> {% endif %}
A Real Gotcha: Filtering Breaks Under Pagination Unless Handled
Django's own built-in pagination (from paginate_by = 25, Chapter 4) generates its "next page" and "previous page" links from page_obj alone — those links only ever carry a ?page=2 query string by default, with no awareness that a ?tag=python filter might also be active. Clicking "next page" while a tag filter is active would silently drop the filter, landing on page 2 of the entire unfiltered catalogue instead of page 2 of the filtered results — a real, easy-to-miss bug that only shows up once a filtered list actually spans more than one page.

The fix is to build pagination links that explicitly carry the active tag forward:

{% if page_obj.has_next %} <a href="?page={{ page_obj.next_page_number }}{% if active_tag %}&tag={{ active_tag }}{% endif %}">Next</a> {% endif %}
Every Link That Touches Pagination Needs the Same Treatment
"Previous page" needs the identical &tag={{ active_tag }} suffix, and so would any other filter this project ever adds later (by item type, say). The real, general rule: any link that changes the page number, on a page whose own results are already filtered, has to explicitly re-include every active filter in its own query string — Django never does this automatically, since the pagination links and the filtering logic are two genuinely separate pieces of code that don't know about each other unless the template explicitly connects them.

A Small Tag Index Page

A real, genuinely useful companion to filtering: a page listing every tag alongside how many items actually carry it, using Django's own real aggregation support:

# catalogue/views.py from django.db.models import Count from .models import Tag class TagIndexView(ListView): model = Tag template_name = 'catalogue/tag_index.html' context_object_name = 'tags' queryset = Tag.objects.annotate(item_count=Count('items')).order_by('-item_count')

Count('items') uses the related_name='items' set on the Item.tags field back in Chapter 2, letting this query count, for each real Tag row, how many Item rows actually reference it — a single aggregated query rather than one count query per tag.

Hands-On Exercises

Exercise 1

Add the tag-filtering logic to ItemListView, make tags clickable in the list template, and confirm visiting /?tag=python shows only items actually tagged Python.

📄 View solution
Exercise 2

Add enough real items tagged "Python" to force pagination onto a second page, then reproduce the real bug this chapter describes by clicking "next page" before applying the fix — confirm the filter is genuinely lost — then apply the fix and confirm it's preserved.

📄 View solution
Exercise 3

Build the TagIndexView and its template, and explain in your own words why Count('items') is a meaningfully faster approach than looping over every tag and calling tag.items.count() individually.

📄 View solution

Chapter 6 Quick Reference

  • Filteringget_queryset() reads a real ?tag= query parameter and filters via tags__name
  • Clickable tags — each tag rendered as a link back into the same filter, plus a "clear filter" link
  • Real bug found — pagination links silently drop the active tag filter unless the query string is explicitly carried forward on every page link
  • TagIndexView — a real aggregated Count('items') query listing every tag with its true item total, in one query
  • Next chapter: Search — real Django ORM querying across the whole catalogue