Custom filters
Subclass BaseFilter when you need an operator the built-in set doesn't cover: a domain-specific check like "is divisible by", a computed condition like "created this month", or support for a field type the default registry skips. This page explains how a filter works internally and shows the two ways to register one, either by subclassing your backend's FilterRegistry to cover every matching field type, or by passing the filter to a single field's filters= list. For the day-to-day details, including the default filters per field type, manual overrides, and the URL format, see the Filters guide.
The BaseFilter interface
Every filter, whether built-in or custom, implements two methods:
from typing import Any
from starlette_admin.filters.base import BaseFilter, FilterApplyContext, FilterDataType
class MyFilter(BaseFilter):
name = "my_filter"
label = "My filter"
data_type = FilterDataType.STRING
def parse_value(self, raw: str) -> Any:
"""Convert the raw string from the URL into the value apply() expects.
Raise FilterValidationError if the value isn't acceptable.
"""
return raw
def apply(self, ctx: FilterApplyContext) -> Any:
"""Return a query fragment for this filter's condition."""
raise NotImplementedError()
parse_value(raw)converts the raw URL string into the typeapply()expects, such as aDecimal, adate, or a list. The default passes the string through unchanged, which suitsSTRINGandENUMfilters but not numeric or temporal data. It's also your validation hook: raiseFilterValidationErrorfor values that parse but are still unacceptable, such as out-of-range or malformed input.apply(ctx)is the only abstract method. It receives aFilterApplyContextholding thequery,field_name,value,value2,request, andview, and returns a query fragment for your backend.
How raw URL values get parsed
Every URL parameter is a string, so price__gt=50 and created_at__eq=2026-01-01 both arrive as raw text. Before apply() runs, parse_value() converts that string into a Python object matching the filter's data_type:
def _parse_number(raw: Any) -> int | float:
text = str(raw).strip()
try:
return int(text)
except ValueError:
pass
try:
return float(text)
except ValueError:
raise FilterValidationError(f"{raw!r} is not a valid number") from None
class GreaterThanFilter(BaseFilter):
name = "gt"
data_type = FilterDataType.NUMBER
def parse_value(self, raw: Any) -> int | float:
return _parse_number(raw)
So ?filter=price__gt=50 and ?filter=price__gt=50.5 reach GreaterThanFilter.apply() as Python numbers (50 as an int, 50.5 as a float) rather than the strings "50" and "50.5". apply() passes that parsed value straight to the query object, and the database driver handles the final coercion against the column's actual type, such as Decimal or Numeric.
data_type |
Example raw URL value | Parsed Python value | Parsed by |
|---|---|---|---|
number |
50, -3, 50.5 |
int(50), int(-3), float(50.5) |
filters.numeric._parse_number (tries int(), falls back to float()) |
date |
2026-01-01 |
date(2026, 1, 1) |
filters.date._parse_temporal using date.fromisoformat() |
datetime |
2026-01-01T14:30:00 |
datetime(2026, 1, 1, 14, 30) |
filters.date._parse_temporal using datetime.fromisoformat() |
time |
14:30:00 |
time(14, 30) |
filters.date._parse_temporal using time.fromisoformat() |
array |
ACTIVE,OUT_OF_STOCK |
["ACTIVE", "OUT_OF_STOCK"] |
filters.array._parse_array (splits on unquoted commas) |
string, enum |
admin |
"admin" |
BaseFilter.parse_value default (passed through unchanged) |
none |
(no value in the URL at all) | (never called) | N/A |
When a value fails to parse, such as price__gt=abc or created_at__eq=not-a-date, parse_value() raises a FilterValidationError. The request handler catches it and returns HTTP 400 before running any database query:
GET /admin/product/list?filter=price__gt=abc
Returns: 400 Bad Request: Invalid 'filter' parameter: 'abc' is not a valid number
Value-less filters, the ones with data_type=none such as is_null, is_true, or in_past, skip this step. parse_value never runs for them, which is why field__is_null needs no =value in the URL: there's no input string to convert.
Making a custom filter available
You can register a custom filter with a view in two ways. Pick the one that matches the scope you want.
Per field instance (narrow scope)
Pass the filter into the target field's filters= list, either alongside the defaults or in place of them. See Overriding filters for a specific field for the same pattern with built-in filters. Use this when the filter only makes sense for one field.
Registry-wide (every matching field type)
Each backend ships a FilterRegistry subclass: SqlaFilterRegistry for SQLAlchemy, BeanieFilterRegistry for Beanie, MongoEngineFilterRegistry for MongoEngine, and TortoiseFilterRegistry for Tortoise ORM. Each one defines the default filters for a supported field type in a method decorated with @filters(FieldType, ...):
# starlette_admin/contrib/sqla/filters.py
class SqlaFilterRegistry(FilterRegistry):
@filters(StringField)
def string_filters(self, field: BaseField) -> list[type[BaseFilter]]:
return [
ContainsFilter,
NotContainsFilter,
EqualFilter,
IsNullFilter,
IsNotNullFilter,
]
@filters(NumberField, FloatField)
def numeric_filters(self, field: BaseField) -> list[type[BaseFilter]]:
return [
NumericEqualFilter,
GreaterThanFilter,
LessThanFilter,
IsNullFilter,
IsNotNullFilter,
]
# ... one method per field type
To change the filters available for a field type across a whole view, subclass the backend's registry, override or add a @filters method, and return an instance of your subclass from get_filter_registry():
class ProductFilterRegistry(SqlaFilterRegistry):
@filters(IntegerField)
def integer_filters(self, field: BaseField) -> list[type[BaseFilter]]:
return [*self.numeric_filters(field), DivisibleByFilter]
class ProductView(ModelView):
def get_filter_registry(self) -> FilterRegistry:
return ProductFilterRegistry()
Declare these methods one of two ways, depending on whether you want to replace the existing filters or extend them:
- Override: Re-declare
@filters(StringField)on your subclass and return exactly the classes you want. This replaces the parent's list, so include any built-in filters you want to keep. - Extend: Declare
@filters(IntegerField)when the parent registry only registers the broaderNumberField. BecauseIntegerFieldsubclassesNumberField, the method resolution order (MRO) resolvesIntegerFieldto your new method, whileDecimalField, anotherNumberFieldsubclass with no registration of its own, keeps inheriting the parent'snumeric_filtersunchanged.
This is a plain Python subclass, so it mutates no global state. Every call to ProductFilterRegistry() builds an independent registry, and your changes stay scoped to the views that return it. All other views keep the backend defaults.
Full SQLAlchemy example
The DivisibleByFilter below takes a value, the divisor to check the column against. A SqlaFilterRegistry subclass applies it to every IntegerField on ProductView, rather than attaching it to individual fields:
import uuid
from contextlib import asynccontextmanager
from datetime import datetime
from decimal import Decimal
from typing import Any
from fastapi import FastAPI
from sqlalchemy import Integer, Numeric, create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from starlette.requests import Request
from starlette_admin import IntegerField
from starlette_admin.contrib.sqla import Admin, ModelView
from starlette_admin.contrib.sqla.filters import SqlaFilterRegistry
from starlette_admin.fields import BaseField
from starlette_admin.filters import (
BaseFilter,
FilterApplyContext,
FilterDataType,
FilterRegistry,
FilterValidationError,
filters,
)
engine = create_engine(
"sqlite:///product.db", connect_args={"check_same_thread": False}, echo=True
)
class Base(DeclarativeBase):
pass
class Product(Base):
__tablename__ = "products"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
name: Mapped[str]
price: Mapped[Decimal] = mapped_column(Numeric(10, 2))
lot_size: Mapped[int] = mapped_column(Integer, default=1)
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
async def __admin_repr__(self, request: Request) -> str:
return self.name
class DivisibleByFilter(BaseFilter):
"""
Filters database rows where the column value is an exact multiple of a given divisor.
"""
name = "divisible_by"
label = "Is divisible by"
data_type = FilterDataType.NUMBER
def parse_value(self, raw: str) -> int:
"""Validates and converts the raw admin UI input into an integer divisor."""
try:
divisor = int(raw)
except ValueError:
raise FilterValidationError(f"{raw!r} is not a valid integer") from None
if divisor == 0:
raise FilterValidationError("divisor must not be 0")
return divisor
def apply(self, ctx: FilterApplyContext) -> Any:
"""Applies the modulus condition to the underlying SQLAlchemy query context."""
column = getattr(ctx.view.model, ctx.field_name)
return column % ctx.value == 0
class ProductFilterRegistry(SqlaFilterRegistry):
"""
Custom filter registry that injects `DivisibleByFilter` into integer fields.
Overriding `integer_filters` gives every IntegerField the divisibility filter
on top of the standard numeric defaults. Other numeric fields, such as
DecimalField, are unaffected.
"""
@filters(IntegerField)
def integer_filters(self, field: BaseField) -> list[type[BaseFilter]]:
return [*self.numeric_filters(field), DivisibleByFilter]
class ProductView(ModelView):
fields = [
"id",
"name",
"price",
# Note: Passing just the string "lot_size" would also work, as SQLAlchemy's
# default converter automatically maps integer columns to IntegerField.
IntegerField("lot_size"),
]
def get_filter_registry(self) -> FilterRegistry:
"""Binds the custom filter registry to this specific view."""
return ProductFilterRegistry()
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(engine)
yield
app = FastAPI(lifespan=lifespan)
admin = Admin(engine, title="Blog Admin", secret_key="change-me")
admin.add_view(ProductView(Product, icon="fa fa-product"))
admin.mount_to(app)
See examples/02-filters for a runnable app with a custom BaseFilter subclass registered the same way.
The lot_size__divisible_by option now shows up as a filter for IntegerField("lot_size"), with no explicit filters= declaration on the field. For example, lot_size__divisible_by=6 matches products whose lot size is a multiple of 6:
Tip
Use a FilterRegistry subclass when a filter is generic enough to apply to every field of a given type in a view. Use the per-field filters= list when the logic belongs to one field only. The Filters guide has examples of the per-field pattern.
Dynamic choices with get_choices
By default, a filter's value input follows its data_type: a plain text box for STRING, a number box for NUMBER, and so on. Override get_choices(request) when the value should come from a dropdown seeded with a per-request list of (value, label) pairs instead. An "is one of" filter over a relation field is the typical case: the value posted back is a foreign key, but the picker should show a readable name.
get_choices receives the current Request and returns a sequence of (value, label) pairs, or None (the default) to leave the plain input in place. A non-empty result wins over both the plain input and any choices the field itself supplies, as EnumField does.
The example below, from examples/advanced/07-hr, adds an "is one of" and "is not one of" pair to the department field on the Employee list. department is a RelationField, so the default registry gives it null checks only: there's no generic way to compare a related row to a raw string. get_choices lists every Department by name for the dropdown, and parse_value converts the posted-back values to integers so apply can match on the Department.id foreign key directly instead of joining through the relationship and comparing names:
# examples/advanced/07-hr/filters.py
from typing import Any
from models import Department, Employee
from sqlalchemy import select
from sqlalchemy.orm import Session
from starlette.requests import Request
from starlette_admin.filters.base import FilterApplyContext, FilterValidationError
from starlette_admin.filters.enum import InFilter, NotInFilter
class _DepartmentChoicesMixin:
"""Shared `get_choices`/`parse_value` for the two filters below: the
filter builder's dropdown lists every department by name, and posts back
the department's `id` rather than its name, so `apply` can match on the
primary key instead of an `ilike` comparison.
"""
def get_choices(self, request: Request) -> list[tuple[int, str]]:
session: Session = request.state.session
return list(
session.execute(
select(Department.id, Department.name).order_by(Department.name)
).all()
)
def parse_value(self, raw: Any) -> list[int]:
values = super().parse_value(raw) # type: ignore[misc]
try:
return [int(v) for v in values]
except ValueError as err:
raise FilterValidationError("Department id must be an integer") from err
class DepartmentInFilter(_DepartmentChoicesMixin, InFilter):
"""Employees in one of the selected departments."""
name = "department_in"
label = "is one of"
def apply(self, ctx: FilterApplyContext) -> Any:
return Employee.department_id.in_(ctx.value)
class DepartmentNotInFilter(_DepartmentChoicesMixin, NotInFilter):
"""Employees not in any of the selected departments"""
name = "department_not_in"
label = "is not one of"
def apply(self, ctx: FilterApplyContext) -> Any:
return ~Employee.department_id.in_(ctx.value)
A few things to note about this pattern:
- The mixin sits before the base filter class in the MRO.
_DepartmentChoicesMixincomes first inclass DepartmentInFilter(_DepartmentChoicesMixin, InFilter), so itsget_choicesandparse_valueoverride the ones each filter would otherwise inherit.super().parse_value(raw)still reachesInFilter.parse_value, which splits the raw value into a list before the mixin converts it to integers. get_choicesruns on every request, not once at import time, so the dropdown always reflects the current rows. A newly addedDepartmentshows up in the filter builder immediately, with no server restart and no cache to invalidate.- The
(value, label)pairs andparse_value's output type have to agree. The dropdown posts back whichevervaluethe user picked, soparse_valueconverts it into whatapplyexpects.Department.idis already aninthere, so the mixin'sparse_valuereasserts that and raises a validation error on anything else. InFilterandNotInFilteralready default todata_type = FilterDataType.ENUM, a multi-select, so neither subclass needs adata_typeoverride. Overridingget_choicesis enough to seed that multi-select with departments instead of leaving it empty.
What's next
- Filters: Learn about default filters per field type, the URL format, and the
filters=override. - SQLAlchemy: Explore the SQLAlchemy backend used in this page's example.
- Extension points: View the complete list of methods you can override on
ModelView.