Querying
Filters
CEL record filters for list operations on every backend, with SQL generation.
Filters select records with CEL (Common Expression Language) expressions. XDB type-checks expressions against the schema when one is available.
One filter string works on every backend. The memory, filesystem, and redis drivers evaluate the CEL expression against each record. The SQLite driver pushes the filter down as a SQL WHERE clause.
A filter is a predicate of the CLI grammar. --filter, --limit, and --offset are flags of xdb records list. --fields is a flag of xdb records list and xdb records get. Run xdb describe --filter for the live reference of operators and functions.
Syntax
xdb records list xdb://myapp/posts --filter 'status == "published"'xdb records list xdb://myapp/posts --filter 'status == "published" && views >= 100' --fields _id,title --limit 10xdb records list xdb://myapp/posts --filter 'title.contains("hello")'xdb records list xdb://myapp/posts --filter 'status in ["active", "pending"]'xdb records list xdb://myapp/posts --filter '!(archived == true)' --fields _idOperators
| Operator | Example | Description |
|---|---|---|
== | status == "active" | Equality |
!= | status != "closed" | Inequality |
> | age > 30 | Greater than |
>= | age >= 18 | Greater or equal |
< | score < 100 | Less than |
<= | score <= 99.9 | Less or equal |
&& | a == 1 && b == 2 | Logical AND |
|| | a == 1 || b == 2 | Logical OR |
! | !(age < 18) | Logical NOT |
in | status in ["a", "b"] | List membership |
Functions
| Function | Example | SQL equivalent |
|---|---|---|
contains | name.contains("oh") | instr(name, ?) > 0 |
startsWith | name.startsWith("J") | substr(name, 1, length(?)) = ? |
endsWith | name.endsWith("hn") | substr(name, -length(?)) = ? |
size | size(name) > 3 | LENGTH(name) > 3 |
matches | name.matches("^J") | no SQL form: scan and evaluate |
timestamp | at >= timestamp("2026-08-01T00:00:00Z") | at >= ? bound as milliseconds |
has | !has(assignee) | NOT ("assignee" IS NOT NULL) |
lowerAscii, upperAscii | name.lowerAscii() == "john" | no SQL form: scan and evaluate |
duration | at <= now + duration("15m") | no SQL form: scan and evaluate |
The case functions come from the CEL strings extension, which also adds
trim, indexOf, replace, split, and substring.
matches, the strings extension, and now have no SQL translation. The
SQLite driver declines the pushdown and the store falls back to a scan with
in-memory evaluation. The result is the same on every backend.
Time comparisons
A time field compares against timestamp("..."):
at >= timestamp("2026-08-01T00:00:00Z") && at < timestamp("2026-09-01T00:00:00Z")A string literal also compares as a time when the other side is a field that
the schema declares as time, or _updated, or now. filter.Compile
rewrites at >= "2026-08-01T00:00:00Z" into the timestamp() form before
the type check. A dynamically typed field can hold a string, so a comparison
against it stays a string comparison.
The argument is an RFC 3339 time. A constant argument that does not parse is
rejected by filter.Compile, because such a filter can never match a record.
filter/sqlgen folds the call at generation time and binds the result as
milliseconds since the Unix epoch, which is how the SQLite driver stores a
time.
The Clock
now is the time of the evaluation, as a time value. Filter.Filter
binds one time for the whole list. now is not reserved: in a list of one
schema that declares a field named now, the field wins, the same way a
declared field wins over a reserved attribute. A list across a namespace
has no schema, so now is always the clock there. To filter on a field
named now, list its schema.
at >= now && at <= now + duration("15m")Absent attributes
has(attr) asks whether the record holds the attribute. The negation selects
the records that do not, such as the unassigned issues of a tracker:
!has(assignee)has(assignee) && done == falseXDB replaces the standard CEL has() macro. An XDB attribute name is flat,
and a dotted name such as author.name is one attribute, not a field of a
nested message. Both has(assignee) and has(author.name) expand to a
membership test on _attrs, so "assignee" in _attrs is the same filter.
Under strict mode, has() on a field that the schema does not declare is
rejected the same way a comparison on that field is.
Case Sensitivity
String matching compares bytes and is case-sensitive, the same as CEL itself.
name.contains("hello") does not match a stored value of "Hello World".
For a match that ignores case, write name.lowerAscii().contains("hello").
This rule is the same on every backend. On SQLite, contains, startsWith,
and endsWith compile to instr and substr, not to LIKE. LIKE in
SQLite ignores ASCII case by default, which gives results different from the
in-memory CEL evaluation on the other backends.
Schema-Aware and Schema-Free Filters
When a schema is available, the filter expression is type-checked against the field definitions. The handling of an unknown field then depends on the schema mode:
-
strict: an unknown field is a compile error. The filter is rejected before it runs. The error names the field and lists the available fields of the schema in sorted order. -
flexibleanddynamic: an unknown field is accepted as a dynamically typed variable. A record that lacks the attribute does not match. There is no error. -
Schema-free (no definition): every variable is dynamically typed, with the same result. A query on a namespace URI, or on a schema that no longer exists, is also schema-free.
The unknown-field check happens in filter.Compile, before any driver sees
the filter. The SQLite driver calls filter.Compile before it generates SQL.
The store facade calls filter.Compile before the in-memory evaluation. Both
paths use the same definition, so the check is the same on every backend.
System Attributes
_id, _version, _updated, and _attrs can be used in a filter in every
mode. You do not declare them, and they never trigger the unknown-field
rejection of strict mode:
_version > 5_updated > timestamp("2026-01-01T00:00:00Z")_id.startsWith("user-")"assignee" in _attrs_attrs is an array<string> of the attribute names the record holds. It is
derived at evaluation time, not stored.
_version and _updated are stamped into every definition, so they are real
columns in a column table. _id is projected from the record path. It is not
stored as a tuple. It resolves to the ID column or key that every backend
already uses. See Versioning.
Relationship to AIP-160
XDB follows the field traversal, comparison, and function-call patterns described by AIP-160, but uses CEL syntax:
| Concept | AIP-160 | XDB (CEL) |
|---|---|---|
| Equality | = | == |
| Logical AND | AND | && |
| Logical OR | OR | || |
| Negation | NOT, - | ! |
CEL supports type checking before evaluation and translation to SQL. Its evaluation model is not Turing-complete.
Go Usage
// Compile once, evaluate many times.f, err := filter.Compile(`status == "active" && age >= 18`, schemaDef)
// Evaluate against one record.match, err := f.Match(record)
// Keep only the records that match.results, err := f.Filter(records)SQL Generation
The filter/sqlgen package converts a compiled filter to parameterized SQL:
wc, err := sqlgen.Generate(f, sqlgen.ColumnStrategy, "")// wc.SQL = `(("status" = ?) AND ("age" >= ?))`// wc.Params = ["active", 18]Column names are double-quoted in the generated SQL. Under ColumnStrategy,
a field that is not declared on the schema of the compiled filter returns
sqlgen.ErrUnknownColumn instead of a raw “no such column” error. This
happens in dynamic mode when a filter references a field that no record has
written yet. The SQLite driver maps this error to a query-pushdown refusal,
and the store falls back to a scan.
A construct with no SQL form, such as matches, returns
sqlgen.ErrUnsupportedExpr. The SQLite driver maps it to the same refusal,
so the filter still runs. Any other fault from sqlgen is bad input, and
reaches the caller as core.ErrInvalidFilter with the INVALID_ARGUMENT
code.
Related Concepts
-
AIP-160: Filtering: Google’s filtering standard
-
AIP-132: List: Standard List method design
-
AIP-158: Pagination: Page token and page size patterns
-
CEL specification: Common Expression Language
-
Stores: How filters integrate with the store facade