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 = ("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.
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