Operator Reference¶
Scalars, arrays of scalars, and nested models (for now)
The current release supports the scalar types below, arrays of
scalars (list[T] / set[T]), and nested Pydantic models via
dotted paths. The remaining compound types are designed (see
design doc 02) and land in
phased releases: arrays of nested models and dict[str, T] keys in
v0.1.3, and FilterSet in v0.2.0. This page grows a table
per type as each ships.
Every operator set below is the default for its type — the safe
profile. Operators listed under full are additional operators
available only when a field (or the whole app) opts into the full profile;
fast-pager never enables them by default because they carry extra cost or
risk (see Safety).
The profiles are only the default layer: which operators a specific field
actually exposes can be overridden per type with
FilterConfig(type_profiles={...}) and per field with
Annotated[T, Filterable(ops=[...])] (including ops.ALL / ops.NONE), or
per route with FilterConfig(operators={...}) — the full precedence ladder
is in the
Controlling the filter surface tutorial.
Whatever the layer, an operator that is not valid for the field's type is
rejected at route registration, never at request time.
Scalar types¶
| Python / Pydantic type | Default operators (safe) |
Additional in full |
|---|---|---|
str |
eq, ne, in, nin, contains, startswith, endswith |
icontains, istartswith, iendswith, regex, text_search |
int, float, Decimal |
eq, ne, gt, gte, lt, lte, in, nin |
between |
bool |
eq |
ne |
datetime, date, time |
eq, ne, gt, gte, lt, lte |
between |
UUID |
eq, ne, in, nin |
|
Enum / Literal[...] |
eq, ne, in, nin |
|
bytes |
(not filterable by default) | eq |
eq is implicit — a bare field=value is treated as field__eq=value, so
the common case (?status=active) stays clean.
Optional[T] / T | None¶
An optional field exposes everything its wrapped type T does, plus:
| Operator | Tier | Meaning |
|---|---|---|
isnull |
safe |
field__isnull=true / false — is the field null? |
exists |
full |
Mongo-flavored presence check ({field: {$exists: bool}}); SQL adapters alias it to isnull semantics or reject it |
Optional[list[T]] works the same way: the array operators below, plus
isnull / exists.
Arrays of scalars — list[T] / set[T]¶
An array field gets operators about membership and shape — never the
element type's scalar operators. tags__contains would be ambiguous
(substring of an element? membership?), so it does not exist; array fields
expose exactly this family, whatever the element type:
| Operator | Tier | Value | Example | Mongo compilation |
|---|---|---|---|---|
has |
safe |
single element | tags__has=python |
{"tags": "python"} (Mongo matches a scalar against array elements) |
has_any |
safe |
element list | tags__has_any=python,rust |
{"tags": {"$in": ["python", "rust"]}} |
has_all |
safe |
element list | tags__has_all=python,rust |
{"tags": {"$all": ["python", "rust"]}} |
len__eq |
safe |
int | tags__len__eq=3 |
{"tags": {"$size": 3}} |
len__ne / len__gt / len__gte / len__lt / len__lte |
full |
int | tags__len__gte=2 |
$expr over $size (see below) |
empty |
safe |
bool | tags__empty=false |
pinned semantics, see below |
Notes, pinned precisely because arrays are where Mongo surprises people:
emptysemantics (empty-vs-missing is a classic Mongo trap):empty=true→ the field exists and is the empty array —{"tags": {"$eq": []}};empty=false→ the field exists and has at least one element —{"tags.0": {"$exists": true}}. A missing field matches neither.emptyreasons only about shape; useisnull/existson anOptional[list[T]]field to reason about presence.lencomparisons arefull-tier because Mongo has no query operator for "length ≥ n": they compile to{"$expr": {"$gte": [{"$size": {"$cond": [{"$isArray": "$tags"}, "$tags", []]}}, 2]}}, which cannot use an index (a collection-scan risk — the same reasoning that gates otherfulloperators). The$isArrayguard means a missing, null, or non-array value counts as length 0 instead of erroring the query.len__eqcompiles to the plain$sizequery operator (which a missing field never matches) and stayssafe.- List values are capped: the
max_list_lengthguard (default 100) applies tohas_any/has_allexactly as it does toin/nin. - No bare-equality sugar: arrays have no
eqoperator, so there is no bare?tags=...parameter. - Arrays are not sortable by default — sorting on a Mongo array field
uses min/max element semantics, which surprises people. Opt a field in
deliberately with
Filterable(sortable=True)or by naming it inFilterConfig(sortable=[...]). - Element-level substring matching (
tags__has_substr) is designed but not shipped yet.
Multi-token names like tags__len__gte need no special parsing rules: every
parameter is pre-generated with its exact name at registration, so the full
spelling is simply matched as-is (see the
filtering tutorial).
Nested Pydantic models (embedded documents)¶
A field whose type is another Pydantic model is walked recursively, and every supported field inside it becomes filterable by dotted path — public parameter names join the segments with the separator, compiled queries use Mongo dot notation:
class Geo(BaseModel):
lat: float
lon: float
class Address(BaseModel):
city: str
tags: list[str]
geo: Geo
class User(BaseModel):
name: str
address: Address
billing: Optional[Address] = None
?address__city__contains=ams # {"address.city": {"$regex": "ams"}}
?address__geo__lat__gte=52 # {"address.geo.lat": {"$gte": 52.0}}
?address__city=Amsterdam # bare eq works on nested leaves too
The rules, precisely:
- Fields inside a nested model get their full normal treatment. A nested
scalar exposes its type's scalar operators (including per-type
type_profilesoverrides), a nestedlist[T]exposes the array family (address__tags__has=home), andFilterable(ops=... / source=... / param=... / sortable=...)on a nested field works exactly as at top level. source/param/ aliases compose per segment. Each path segment resolves its two names independently (param→ alias → field name for the URL;source→ alias → field name for the database), soFilterable(source="zip")onAddress.zip_codeyields?address__zip_code=...→{"address.zip": ...}, and a rename on the embedding field renames that segment for the whole subtree.- The embedding field itself has no operators of its own — no bare
?address=parameter exists — with one exception: anOptionalembedding exposesisnull(safe) andexists(full), so?billing__isnull=truefinds documents without a billing address. The children of a nullable embedding are generated normally; note that when the parent isnull, a condition on a child simply matches nothing — combine withbilling__isnull=falsewhen presence matters. - Depth is bounded by
FilterConfig(max_depth=...), default 2: a field's path may cross at mostmax_depthembedded-model boundaries.address__city(1) andaddress__geo__lat(2) are generated; anything deeper is silently skipped. A nested model sitting exactly at the bound keeps its ownisnull/exists(when nullable) but none of its children. The bound is what keeps the parameter surface finite — and it is also the cycle guard: self-referential or mutually-recursive models simply truncate at the bound. - Subtree opt-out:
Filterable(ops=ops.NONE)on the embedding field removes the field and every descendant from the filter surface. OtherFilterableoptions on an embedding field never touch the children —ops=[...]is validated against the embedding's own operator set (isnull/existswhen nullable, nothing otherwise). Route-level,FilterConfig(exclude=["billing"])excludes the subtree, andexclude=["address__city"]a single nested field. - Config keys use the public dotted-param spelling:
FilterConfig(operators={"address__city": ["contains"]}),sortable=["address__city"],exclude=["address__city"]. - Nested leaves sort by their public name —
?sort=-address__citycompiles to the dotted sourceaddress.city. Scalar leaves are sortable by default (sortable-iff-filterable, as at top level); embedding fields and nested arrays are not. - Name collisions are impossible to mis-parse and loud to misconfigure:
because matching is exact, a literal field named
address__cityand a nestedaddress.citypath that generate the same parameter name raise aConfigurationErrorat registration naming both sources. list[NestedModel]element matching (elem→$elemMatch) ships inv0.1.3.
Operator semantics¶
| Operator | Value arity | Example | Mongo compilation |
|---|---|---|---|
eq |
single | age__eq=21 (or bare age=21) |
{"age": 21} |
ne |
single | age__ne=21 |
{"age": {"$ne": 21}} |
gt / gte / lt / lte |
single | age__gte=21 |
{"age": {"$gte": 21}} |
between |
range (2 values) | age__between=21,65 |
sugar for gte + lte |
in / nin |
list | status__in=a,b or repeated status__in=a&status__in=b |
{"status": {"$in": ["a", "b"]}} |
contains |
single | name__contains=ana |
{"name": {"$regex": "ana"}} (value re.escape()d) |
icontains |
single | name__icontains=ANA |
as contains, case-insensitive |
startswith / endswith |
single | name__startswith=al |
anchored, escaped $regex |
regex |
single | name__regex=^a.*z$ |
raw $regex — full-tier, gated by config, disabled by default |
text_search |
single | name__text_search=alice |
Mongo $text over a text index (requires backend capability) |
isnull |
bool | email__isnull=true |
{"email": null} |
exists |
bool | email__exists=false |
{"email": {"$exists": false}} |
List-value operators (in, nin, and the array operators has_any /
has_all) accept both comma-joined values and repeated query keys; each
element is coerced to the field's (element) type. A max_list_length guard
(default 100) caps how many values are accepted.
Safety notes¶
regexisfull-only and gated by an additional config flag — full pattern matching is a ReDoS and collection-scan risk.contains/icontainsvalues are alwaysre.escape()d before being compiled, so user input is a literal substring, never a pattern —containsitself can never become a ReDoS vector. It still compiles to an unanchored$regex, so it can still trigger a collection scan on an unindexed field; reach fortext_searchwhen that matters.- Case-insensitive variants (
i*) are separate, explicit operators rather than a flag, so they show up by name in the generated docs and compile to explicit backend forms.
Full detail, including the rationale for each default, lives in design doc 02 — Type & Operator System.