Filters and search

One tuple, six shapes of control — and the two kinds of field FastFort refuses to filter on, with the reason.

Available since v0.1.0

You never name a control for a filter. list_filter names columns; the column’s own type picks the control.

class ProductAdmin(admin.ModelAdmin):
    list_filter = ("availability", "is_active", "category", "supplier",
                   "price", "released_on")

What each type produces

Column typeControl
EnumA dropdown of the column’s own members, read off the column
BooleanThree states: any, yes, no — never a checkbox
Nullable booleanFour, because “not set” is its own answer
Foreign key · one-to-oneA picker, autocomplete-backed once the target outgrows a dropdown
Integer · float · decimal · moneyTwo bounds, from and to, either optional
Date · datetimeA range, with presets — today, last 7 days, this month
Time · durationThe same two-bound shape, in the column’s own units
UUIDExact match

The two refusals

Two kinds of field are not filterable, and the refusal happens when the ModelAdmin is built rather than on the page:

ConfigurationError: SupplierAdmin is misconfigured:
  - list_filter names 'country'; free-text and multi-valued fields
    cannot be offered as a filter

Free text

A String or Text column. Its dropdown would be every distinct value in the table — on customers.name that is one option per customer, fetched on every page load.

This catches things that look filterable. A String(2) country code is a closed set in your head, and free text as far as the column is concerned:

country: Mapped[str] = mapped_column(sa.String(2), default="UZ")   # not filterable

The fix is to make the set explicit, at which point it filters:

class Country(enum.StrEnum):
    UZ = "UZ"
    KZ = "KZ"
    TR = "TR"

country: Mapped[Country] = mapped_column(sa.Enum(Country, name="country"))

Many-to-many

Multi-valued, so there is no single value to match a filter against. tags on a product is a list; “products whose tags are bestseller” is not a well-formed question without deciding whether you mean any or all.

Both remain searchable, sortable and displayable. Only the filter panel is closed to them.

search_fields = ("id", "sku", "name", "description")

Without any search_fields the box is not drawn at all — better than a box that silently matches nothing.

Two kinds of column qualify, and they are matched differently.

Text is matched by substring, case-insensitively, on every database: STRING, TEXT, EMAIL, URL. INET is deliberately excluded even though it reads like text, because PostgreSQL has no LIKE for inet — an icontains over one fails with operator does not exist, and a search box that 500s is worse than one that skips the column.

Ids are matched exactly: INTEGER, BIGINT and UUID. This is the thing everybody actually types into an admin’s search box — an id arrives off a support ticket, an invoice or a log line — and search_fields = ("id", "name") used to be a configuration error rather than the obvious line it looks like.

Exactly, rather than by substring, for two reasons. Searching 1 should return row 1, not rows 1, 12, 21 and 100; and equality uses the primary key index, where casting a bigint to text to run LIKE '%1%' over it is a full scan of the table whose id column is the best index on it.

A term that cannot be the column’s type at all simply drops that half of the query. Searching pixel over ("id", "name") searches the name; searching 42 searches both. Searching a word when id is the only search field returns nothing — which is the right answer, and not the same thing as returning everything.

Everything else is still a build-time error, with the reason:

- search_fields names 'price', which is a decimal column. Search matches
  text fields by substring and integer and UUID fields exactly; there is
  no useful reading of a search term for this one. Offer it through
  list_filter instead.

Relations are searched through their own columns by naming the path:

search_fields = ("id", "name", "category__name")

Filtering by URL

ListQuery.from_params is the only place raw query-string input becomes a query. Sort keys, filter fields and operators are all checked against allow-lists derived from the spec; nothing downstream re-validates, because nothing downstream has to.

?availability=in_stock                 exact
?price__gte=50000&price__lte=200000    a range
?released_on__range=2026-01-01,2026-06-30
?category__isnull=true
?q=cable                               the search box
?o=-created_at                         ordering
?ps=100                                page size, capped at admin.max_page_size

Operators available: exact, iexact, ne, lt, lte, gt, gte, in, isnull, range, icontains, istartswith, iendswith.

Anything not on the allow-list for that field is rejected rather than passed through — a filter on a column the admin never offered is not reachable by typing it into the address bar.

Saved views

Because a filter is a query string, it is already shareable — copy the URL, send it. What was missing was a name for one worth coming back to.

A Views menu beside the filter button keeps a shelf of named URLs, entirely in the browser: nothing is written server-side, because the query string was already the source of truth and a second copy of it would only be a second place for the two to disagree. Recalling a saved view always starts at its first page; the page number was never part of what the view meant.

There is no admin option for this — it needs none. Any list a project’s own ModelAdmin already renders gets the Views menu for free, the same way it gets Export.

Column visibility

A wide model charges every row for every column, whether or not anyone reading this screen cares about that one. A Columns menu — one toggle per displayed column — hides what is not worth the width, remembered per list, per browser, in localStorage.

It is a display preference, not a query: a hidden column is still fetched and still exportable, it is only not drawn. Nothing here is ModelAdmin configuration either, for the same reason Saved views needs none — every list already has it.

Spatial filters

On a geometry or geography column:

?catchment__within=POLYGON((...))
?catchment__intersects=POINT(69.28 41.31)
?last_seen__dwithin=41.3111,69.2797,5000
?catchment__bbox=69.1,41.2,69.4,41.4

Also contains, overlaps, touches, crosses.

dwithin is the one to be careful with, and FastFort is careful for you: the third number is metres on a geography column and SRID units — degrees, on 4326 — on a geometry. The spec carries a geography flag precisely so the two cannot be collapsed, and the query casts where it has to.

On a pgvector column:

?embedding__near=[0.4,0.1,0.9]&embedding__k=5

Ranks nearest-first, and that ordering beats whatever else the page was asked for — “the nearest, and break ties by name” rather than the alphabet.

cosine (default), l2, l1 and inner select the distance operator, and a bound is available:

?embedding__near=[0.4,0.1,0.9]&embedding__metric=l2&embedding__within=0.35