Operator Reference¶
The full compound-type surface
The current release supports the scalar types below, arrays of
scalars (list[T] / set[T]), nested Pydantic models via dotted
paths, arrays of nested models (list[NestedModel] via elem →
$elemMatch), and dict[str, T] maps (explicitly enabled, key
presence + enumerated keys). That completes the type tables of
design doc 02; the
FilterSet class (allow-list filter surfaces per model) lands in
v0.2.0.
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.
Arrays of nested models — list[NestedModel]¶
A field whose type is a list of Pydantic models gets element matching via
the elem path segment:
class Order(BaseModel):
amount: float
status: Literal["paid", "refunded"]
class User(BaseModel):
name: str
orders: list[Order]
compiles to a single $elemMatch:
Same-element vs. independent conditions — the $elemMatch surprise
This is the whole point of the elem token, and it is where Mongo
surprises people most. All conditions on the same orders__elem__...
prefix in one request must hold for the same array element — the
query above finds users with one order that is both ≥ 100 and
refunded.
Mongo's default array-matching semantics are the opposite:
{"orders.amount": {"$gte": 100}, "orders.status": "refunded"} matches
a user whose ≥-100 order and refunded order are different elements.
fast-pager deliberately does not generate those independent
dotted-path parameters for arrays of models — if a request names an
elem path, you get same-element semantics, always. (Independent
conditions across different array fields remain independent: each
array field gets its own $elemMatch.)
The rules, precisely:
elemparameters arefull-tier as a whole — precisely because of the subtlety above (design doc 02). Under the defaultsafeprofile noelemparameters are generated; opt in withFilterConfig(default_profile="full"), withFilterable(ops=[...])on the element model's field, or with aFilterConfig(operators= {"orders__elem__amount": [...]})entry naming theelempath.- Fields inside the element get their normal operator surface (scalar
operators, the array family for a
list[T]inside the element,Filterablerenames —source=composes relatively: inside$elemMatch, keys are relative to the element). One exception:text_searchis collection-level and never applies inside elements — explicitly configuring it on anelempath is aConfigurationError. - The array field itself gets the shape operators —
len__eq/empty(safe) and thelencomparisons (full), compiled exactly as for arrays of scalars — plusisnull/existswhenOptional. It does not gethas/has_any/has_all(element equality against whole documents is not expressible through typed query parameters), and there is no bare?orders=parameter. A shape condition and anelemgroup on the same field merge:{"orders": {"$size": 2, "$elemMatch": {...}}}. - The
elemhop counts as onemax_depthboundary, exactly like an embedding: with the defaultmax_depth=2,orders__elem__amount(1) andorders__elem__items__elem__qty(2, a nested array inside the element) are generated. Cycles truncate at the bound, as for embeddings. elempaths are never sortable. "Sort users by the amount of an order" has no per-document meaning; naming anelempath inFilterConfig(sortable=[...])is aConfigurationError, andFilterable(sortable=True)on an element model's field is ignored for itselemuses.- Subtree opt-out works as for embeddings:
Filterable(ops=ops.NONE)on thelist[NestedModel]field (orFilterConfig(exclude=["orders"])) removes the field and everyelemdescendant;exclude=["orders__elem__amount"]removes a single element field.
Maps — dict[str, T]¶
Free-form maps clash with a core promise — every parameter is
pre-generated, typed, and documented in OpenAPI — so support is
deliberately narrow, and maps are not filterable by default: a plain
dict[str, T] field generates nothing. A Filterable annotation enables
it:
class User(BaseModel):
metadata: Annotated[dict[str, str], Filterable(keys=["region", "tier"])]
counters: Annotated[dict[str, int], Filterable()]
attrs: dict[str, str] # no annotation → not filterable
| Parameter | Enabled by | Value | Mongo compilation |
|---|---|---|---|
metadata__has_key=region |
any Filterable(...) on the field |
the key name (str, whatever T is) |
{"metadata.region": {"$exists": true}} |
metadata__region=emea |
only keys enumerated in Filterable(keys=[...]) |
typed as T, implicit eq only |
{"metadata.region": "emea"} |
has_keyvalues are sanitized. The key is user input inserted into a backend field path, so keys that are empty or contain.,$, or null bytes are rejected with a standard 422 (and the Mongo compiler re-checks and raises for direct AST users — defense in depth). The same rule applies, at registration time, to the keys listed inFilterable(keys=[...]).- Value-at-key parameters exist only for enumerated keys — there is no
request-time
metadata__<anything>; un-enumerated spellings are unknown parameters (a 422 in strict mode). Each enumerated key gets a typed,eq-only parameter (metadata__region/metadata__region__eq); configuring any other operator on it is aConfigurationError. Richer per-key operator sets may come withFilterSet. - Type constraints are enforced at registration:
Filterableon a baredict, a non-strkey type, or an unsupported value type raisesConfigurationError, as doesFilterable(keys=[...])on a non-map field. - Maps are not sortable by default (and expose no bare
?metadata=parameter). An enumerated key path may be opted into sorting viaFilterConfig(sortable=["metadata__region"])— it compiles to the dottedmetadata.region.Optional[dict[str, T]]addsisnull(safe) andexists(full).
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}} |
has_key |
single (the key name) | metadata__has_key=region |
{"metadata.region": {"$exists": true}} — key sanitized, see Maps |
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.