Filtering and sorting
Every connection — root field or to-many relation — takes a where argument for filtering and an
order_by argument for sorting. Both are generated from your definitions, so the operators available on
a field follow from that field's type.
The examples use the equipment and maintenance_request objects introduced in the
overview.
Operators by field type
| Field type | Operators |
|---|---|
ID (string + format: uuid) | _eq, _in, _gt, _lt, _gte, _lte |
String | _eq, _in, _like, _ilike, _is_null |
Float (number) | _eq, _in, _gt, _lt, _gte, _lte, _is_null |
Boolean | _eq, _is_null |
| Enum | _in only |
DateTime, Date, Time | _eq, _in, _gt, _lt, _gte, _lte, _is_null |
| Array | _contains, _contained_in, _has_key, _is_null |
object property | Not an operator set — filter its inner fields, see Nested objects |
Two of these are narrower than you might expect, and deliberately so:
IDhas no_is_null. A record always has anid. It does have the ordering comparators, which exist soidcan complete a compound cursor — see exhaustive iteration.- Enums accept only
_in, and the operand is a list of strings. The output field is a GraphQL enum, but the filter takes_in: ["hvac"]— quoted — not the bare enum literal[hvac]. Use a one-element list for a single value; there is no_eqon an enum field.
Every operator on one field is combined with AND. { name: { _like: "Chiller%" }, operational: { _eq: true } }
means both conditions.
query UrgentOpenRequests {
maintenance_request(
where: {
resolved: { _eq: false }
priority: { _in: ["urgent"] }
reported_at: { _gte: "2026-03-01T00:00:00Z" }
}
order_by: [{ reported_at: desc }]
first: 20
) {
totalCount
edges {
node {
id
title
reported_at
}
}
}
}
Text matching
_like is case-sensitive and _ilike is case-insensitive. Both use SQL pattern syntax: % matches any
sequence of characters, _ matches exactly one.
A pattern may contain at most two % wildcards. A third is rejected with
validation-failed — leading-and-trailing wildcards on both ends of a long pattern force a scan the
database cannot index.
{ equipment(where: { name: { _ilike: "%chiller%" } }, first: 10) { edges { node { name } } } }
Null checks
_is_null: true matches records where the field is unset; _is_null: false matches those where it is
set. This is separate from _eq: null, which is not supported.
Array fields
An array property is stored as JSON, so it is filtered by containment rather than by comparison:
| Operator | Meaning |
|---|---|
_contains | The array contains the given JSON value. |
_contained_in | Every element of the array appears in the given JSON value. |
_has_key | The given key is present (for arrays of objects). |
The operand is a JSON string, not a GraphQL list, and is limited to 3,000 characters:
{ equipment(where: { certifications: { _contains: "[\"TUV\"]" } }, first: 10) { edges { node { name } } } }
Combining conditions
_and: [Filter!] | All of the given filters must match. |
_or: [Filter!] | At least one must match. |
_not: Filter | The given filter must not match. |
query NeglectedOrCritical {
equipment(
where: {
_or: [
{ operational: { _eq: false } }
{ _and: [{ category: { _in: ["hvac"] } }, { replacement_cost: { _gte: 20000 } }] }
]
_not: { asset_tag: { _like: "TEMP-%" } }
}
first: 25
) {
totalCount
edges { node { name asset_tag } }
}
}
Two edge cases are worth knowing because they are opposites:
_and: []— an empty list — matches everything._or: []matches nothing.
That follows from the logic (an empty conjunction is true, an empty disjunction is false), but it bites
when a client builds the list dynamically: an _or whose conditions were all filtered out returns zero
rows rather than being ignored. Omit the key entirely when you have no conditions.
Nesting limit
A where argument may nest at most 4 levels. Each _and, _or, _not and each nested-object
filter counts as one level. Exceeding it returns graphql/filter-too-deep, which reports both the depth
you sent and the limit.
This limit is independent of the query-depth limit that bounds relation traversal — they are separate controls with separate values. See Errors and limits.
Nested objects
A property of type object is returned as a JSON string, but it is filterable and sortable by its
inner fields. The generated filter input mirrors the nested schema:
query PowerfulChillers {
equipment(
where: { specs: { power_kw: { _gte: 40 }, manufacturer: { _eq: "Kelvion" } } }
order_by: [{ specs: { power_kw: desc } }]
first: 10
) {
edges { node { name specs } }
}
}
Nesting follows your definition's own structure, up to the maximum schema depth of 3.
Filtering by a related record
A relation contributes a field to the filter input, named after the relation slug and typed as the other side's filter. It matches a parent when at least one related record matches:
query EquipmentWithUrgentRequests {
equipment(
where: { maintenance_requests: { priority: { _in: ["urgent"] }, resolved: { _eq: false } } }
first: 20
) {
totalCount
edges { node { name } }
}
}
Note the semantics carefully: this returns equipment that has any unresolved urgent request. It does
not filter the maintenance_requests connection itself. To do both — select the equipment and narrow
the requests you get back — filter in both places:
{
equipment(where: { maintenance_requests: { resolved: { _eq: false } } }, first: 20) {
edges {
node {
name
maintenance_requests(where: { resolved: { _eq: false } }, first: 5) {
edges { node { title } }
}
}
}
}
}
Hidden relation sides contribute no filter field, as they contribute no output field.
Sorting
order_by takes a list, and the list order is the sort precedence:
{
equipment(order_by: [{ category: asc }, { name: asc }], first: 50) {
edges { node { category name } }
}
}
Directions are asc and desc. Nested-object fields sort with the same shape used for filtering:
order_by: [{ specs: { power_kw: desc } }].
lock_version cannot be sorted on; every other field, including the system fields id, created_at
and updated_at, can.
Results are always in a total order
This matters more than it sounds, because it is what makes paging trustworthy. PostgreSQL guarantees no row order without an explicit sort, so an unordered query combined with a page window could return the same row on two pages and never return another.
So every query is given a total order:
- Your
order_byfields first, in the order you listed them. - Then
created_atascending, if you supplied no sort at all — so an unsorted list is oldest-first rather than arbitrary. - Then
idascending, always, which breaks any remaining tie.
You never have to add a tiebreaker yourself, and two identical queries return pages that fit together.