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 type | Control |
|---|---|
| Enum | A dropdown of the column’s own members, read off the column |
| Boolean | Three states: any, yes, no — never a checkbox |
| Nullable boolean | Four, because “not set” is its own answer |
| Foreign key · one-to-one | A picker, autocomplete-backed once the target outgrows a dropdown |
| Integer · float · decimal · money | Two bounds, from and to, either optional |
| Date · datetime | A range, with presets — today, last 7 days, this month |
| Time · duration | The same two-bound shape, in the column’s own units |
| UUID | Exact 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.
The search box
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.
Vector search
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