skip to content

In Django's admin, how do list_filter and search_fields narrow a change list, and how is a multi-word search turned into a query?

level: middleimportance: must knowfreq 52%

answer

  1. sidebar versus search box
  2. field names, filter classes, tuples
  3. split into terms
  4. AND across terms, OR across fields
  5. prefixes ^ = @

basics

~20 s

list_filter adds sidebar filters built from field names, SimpleListFilter subclasses or (field, FieldListFilter) pairs. search_fields adds a search box: each word must match at least one listed field, using icontains unless a lookup or prefix says otherwise.

solid answer

~40 s

`list_filter` builds the right-hand sidebar. Each entry is a field name (including `__` paths such as `customer__company`), a `SimpleListFilter` subclass with `lookups()` and `queryset()`, or a `(field, FieldListFilter)` pair such as `("assignee", admin.RelatedOnlyFieldListFilter)`. Choices become query-string parameters; lookups on the model's own fields are accepted, but a lookup that spans relations must match a configured filter or `date_hierarchy`, or the admin raises `DisallowedModelAdminLookup`. `search_fields` adds a search box. Django splits the input into terms (quoted phrases stay together) and requires **every term** to match **at least one** field, so `refund acme` becomes `(subject ILIKE '%refund%' OR customer__company__name ILIKE '%refund%') AND (... '%acme%' ...)`. Text fields use `icontains` by default; `^`, `=` and `@` prefixes mean `istartswith`, `iexact` and `search`. Searching across a many-valued relation can duplicate rows, so the admin adds `distinct()`.

code

python · 31 lines
python
from datetime import timedelta

from django.contrib import admin
from django.utils import timezone

from helpdesk.models import Ticket


class SlaFilter(admin.SimpleListFilter):
    title = "SLA"
    parameter_name = "sla"

    def lookups(self, request, model_admin):
        return [("breached", "Breached"), ("ok", "Within SLA")]

    def queryset(self, request, queryset):
        cutoff = timezone.now() - timedelta(hours=4)
        open_high = queryset.filter(status=Ticket.Status.OPEN, priority=Ticket.Priority.HIGH)
        if self.value() == "breached":
            return open_high.filter(opened_at__lt=cutoff)
        if self.value() == "ok":
            return queryset.exclude(pk__in=open_high.filter(opened_at__lt=cutoff))
        return None


@admin.register(Ticket)
class TicketAdmin(admin.ModelAdmin):
    list_display = ["reference", "subject", "status", "priority", "assignee"]
    list_filter = ["status", "priority", ("assignee", admin.RelatedOnlyFieldListFilter), SlaFilter]
    search_fields = ["=reference", "subject", "customer__email", "customer__company__name"]
    search_help_text = "Reference (exact), subject, customer e-mail or company"

go deeper

for a junior

Know that list_filter adds sidebar filters and search_fields adds a search box, and that search uses icontains by default.

for a middle

Explain the AND-of-ORs search, quoted phrases, the lookup prefixes, SimpleListFilter's lookups() and queryset(), and the distinct() added for many-valued paths.

for a senior

Show you keep search fields few and indexed, use RelatedOnlyFieldListFilter on big related tables, and know the URL lookups are whitelisted.

for a principal

Decide when the admin's search stops being enough and staff need a dedicated search tool instead.

## Two ways to narrow the list A Django admin change list can be narrowed from the **sidebar** (`list_filter`) and from the **search box** (`search_fields`). Both translate a query string into ORM filters on the `ModelAdmin`'s queryset, and both combine with each other and with the `date_hierarchy` drill-down. ## list_filter entries | Entry | Example | What you get | |---|---|---| | Field name | `"status"` | a filter chosen by field type (boolean, choices, date, related, all distinct values) | | Field path | `"customer__company"` | a filter on a related model's field | | `SimpleListFilter` subclass | `SlaFilter` | your own `title`, `parameter_name`, `lookups()` and `queryset()` | | `(field, FieldListFilter)` tuple | `("assignee", admin.RelatedOnlyFieldListFilter)` | a built-in filter class you opt into | Useful built-in opt-ins: `RelatedOnlyFieldListFilter` lists only related objects actually referenced (only agents who have tickets, instead of every user), and `EmptyFieldListFilter` filters empty strings and nulls. A `SimpleListFilter` is how you express business states that are not a single column: "SLA breached" might mean open, high priority and older than four hours. `lookups()` returns `(value, label)` pairs; `queryset()` reads `self.value()` and returns the filtered queryset, or `None` to leave it unchanged. ### Query-string safety Filters are just URL parameters, so the admin guards them in `ModelAdmin.lookup_allowed()`. Lookups on the model's own fields are allowed, but a lookup that **spans relations** must correspond to an entry in `list_filter` or to `date_hierarchy`; anything else raises `DisallowedModelAdminLookup`, a `SuspiciousOperation`. That stops a staff member from probing, say, `?customer__user__password__startswith=` through the URL. ## How search_fields builds the query 1. The search text is split into **terms**; a quoted phrase such as `"double charge"` stays one term. 2. For every term, Django builds an `OR` across all `search_fields`. 3. The per-term groups are combined with `AND`, so every term must match somewhere. 4. Text fields use `icontains` unless the entry names a lookup (`"reference__exact"`) or uses a prefix. 5. If any searched path crosses a many-valued relation, the results may contain duplicates, and the change list applies `distinct()`. The prefixes are an older shorthand: - `^subject` means `subject__istartswith`, a prefix match rather than a substring match. - `=reference` means `reference__iexact`. - `@body` means the `search` lookup, which is full-text search provided by `django.contrib.postgres`. For non-text fields with an explicit lookup such as `id__exact`, a term that cannot be converted to that type is **skipped** for that field. That skipping is new in **Django 6.1**; earlier versions did not skip invalid terms. ## What the URL carries Every sidebar choice is a link that adds a query-string parameter, for example `?status__exact=open` for a choices field or `?sla=breached` for a `SimpleListFilter` whose `parameter_name` is `"sla"` (it is required; a filter without one raises `ImproperlyConfigured`). The search term travels as `?q=`. Because the state lives in the URL, a filtered queue can be bookmarked or pasted into a chat. With `preserve_filters = True` (the default), opening a ticket and saving it returns the agent to the same filtered, searched list instead of the unfiltered first page. ## Customising further `get_search_results(request, queryset, search_term)` returns `(queryset, may_have_duplicates)` and is the hook for anything the declarative options cannot express, such as recognising a ticket number pattern and jumping straight to an exact match. `search_help_text` puts a hint under the box. ## Support-ticket settings that work - `list_filter = ["status", "priority", ("assignee", admin.RelatedOnlyFieldListFilter), SlaFilter]` - `search_fields = ["=reference", "subject", "customer__email", "customer__company__name"]` Keep `search_fields` short: every entry adds an `OR` branch per term, and every relation path adds a join.

  • Why can a Django admin search across customer__orders__number show the same ticket twice?
    The path crosses a many-valued relation, so the join yields one row per matching order. The admin detects that the lookup can spawn duplicates and applies `distinct()` to the queryset. The rows are then unique, but `DISTINCT` on a large join has its own cost, which is one reason to keep many-valued paths out of `search_fields`.
  • What does a Django SimpleListFilter's queryset() return to leave the list unfiltered?
    It returns `None` (or the queryset unchanged) when `self.value()` is not one of its lookups, which is the case when the sidebar shows "All". The change list then keeps the queryset as it was. Returning an empty queryset instead would hide every row.

saying these in an interview costs you the question

  • A multi-word admin search matches rows containing any one of the words
  • search_fields uses an exact match on every field by default
  • list_filter only accepts field names on the model itself, never related paths
  • Any lookup typed into the change list URL is applied without checks
  • The ^ prefix in search_fields means a regular expression search