Skip to main content

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 typeOperators
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 propertyNot an operator set — filter its inner fields, see Nested objects

Two of these are narrower than you might expect, and deliberately so:

  • ID has no _is_null. A record always has an id. It does have the ordering comparators, which exist so id can 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 _eq on an enum field.

Every operator on one field is combined with AND. { name: { _like: "Chiller%" }, operational: { _eq: true } } means both conditions.

urgent-open-requests.graphql
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.

filter-ilike.graphql
{ 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:

OperatorMeaning
_containsThe array contains the given JSON value.
_contained_inEvery element of the array appears in the given JSON value.
_has_keyThe 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:

certifications-contains.graphql
{ 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: FilterThe given filter must not match.
neglected-or-critical.graphql
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:

powerful-chillers.graphql
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.

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:

equipment-with-urgent-requests.graphql
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:

filter-both-levels.graphql
{
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:

order-by-category-name.graphql
{
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_by fields first, in the order you listed them.
  • Then created_at ascending, if you supplied no sort at all — so an unsorted list is oldest-first rather than arbitrary.
  • Then id ascending, always, which breaks any remaining tie.

You never have to add a tiebreaker yourself, and two identical queries return pages that fit together.