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 = ("sku", "name", "description")

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

Only text-like types qualify: 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.

Naming a non-text column is a build-time error:

- search_fields names 'price', which is not a text field

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.

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