Filtering
where chooses which rows a read returns, or which rows a PATCH or DELETE touches. It uses the same filter syntax as row filters and payload checks, written in the query string with brackets:
where[<field>][<operator>]=<value>
where[status][_eq]=ASSIGNED
where[due_at][_lt]=$CURRENT_TIMESTAMP
where[site.customer.name][_ilike]=%mercado%
Your where is combined with your row filters using AND. It can narrow what you may see, never widen it.
Operators
| Operator | Means | Value | Example |
|---|---|---|---|
_eq | equal to | one value | where[status][_eq]=NEW |
_neq | different from | one value | where[status][_neq]=CANCELLED |
_gt | greater than | one value | where[total][_gt]=1000 |
_gte | greater than or equal to | one value | where[total][_gte]=1000 |
_lt | less than | one value | where[due_at][_lt]=2026-11-01 |
_lte | less than or equal to | one value | where[due_at][_lte]=2026-10-31 |
_like | matches a pattern: % any text, _ one character | a pattern | where[number][_like]=WO-0012% |
_ilike | matches a pattern, ignoring case | a pattern | where[name][_ilike]=%güemes% |
_regex | matches a POSIX regular expression | a regular expression | where[phone][_regex]=^387 |
_in | one of the values | values separated by commas | where[status][_in]=NEW,ASSIGNED |
_nin | none of the values | values separated by commas | where[status][_nin]=COMPLETED,CANCELLED |
_between | between two values, both included | two values separated by a comma | where[reported_at][_between]=2026-10-01,2026-10-31 |
_null | has no value | true (ignored) | where[technician][_null]=true |
_nnull | has a value | true (ignored) | where[technician][_nnull]=true |
_contains | the array or jsonb contains the value | an array literal ({A,B}) or JSON | where[skills][_contains]={HVAC} |
_contained_by | every element is in the value | an array literal or JSON | where[skills][_contained_by]={HVAC,SOLAR} |
_overlap | shares at least one element with the value | an array literal | where[skills][_overlap]={SOLAR,ELECTRICAL} |
Row filters › Operators explains each operator in detail, including how rows with no value behave.
A value is converted to the column's type: true or false for booleans, a number for numeric columns (400 INVALID_TYPE otherwise). Anything else is cast by PostgreSQL. Remember to URL-encode values: % is %25, a space is %20. curl -G --data-urlencode does it for you.
Several conditions
Conditions on different fields combine with AND:
where[status][_eq]=ASSIGNED&where[priority][_eq]=URGENT
So do two operators on the same field:
where[quantity][_gt]=0&where[quantity][_lte]=50
For OR and NOT, use _or, _and and _not. _or and _and take a list, written with indexes:
where[_or][0][priority][_eq]=URGENT&where[_or][1][due_at][_lt]=$CURRENT_TIMESTAMP
That reads "urgent, or overdue". _not takes one filter:
where[_not][status][_in]=COMPLETED,CANCELLED
Groups nest: where[_or][0][_and][0][...]. A query string holds at most 1000 values and 64 bracket levels per key; past them the request is refused (QUERY_PARAMETER_LIMIT_EXCEEDED, QUERY_DEPTH_LIMIT_EXCEEDED). Filters can nest up to api.max_filter_depth levels (10).
Relation paths
Any field can be a dotted path through foreign keys, up to three steps:
where[site.branch.code][_eq]=SAL
where[technician.user.email][_ilike]=%@acme-field.example
You need READ on each path you filter by, as for the fields you read.
Variables
| Variable | Value |
|---|---|
$USER.<column> | a column of your own user row: $USER.id, $USER.branch |
$CURRENT_TIMESTAMP, $CURRENT_DATE, $CURRENT_TIME | now, today, the current time (PostgreSQL's) |
$INTERVAL.<n><unit> | now minus a duration: $INTERVAL.15m, $INTERVAL.24h, $INTERVAL.7d, $INTERVAL.2w, $INTERVAL.1y |
$PERIOD.<period>.from / .to | the start (included) and end (excluded) of a calendar period |
$ROW.<column> | another column of the same row |
Periods: today, yesterday, this_week, last_week, this_month, last_month, this_quarter, last_quarter, this_year, last_year. Weeks start on Monday. PostgreSQL calculates them in your time zone, so "today" starts at your midnight.
# orders reported this month
where[reported_at][_gte]=$PERIOD.this_month.from&where[reported_at][_lt]=$PERIOD.this_month.to
# changed in the last 24 hours
where[updated_at][_gte]=$INTERVAL.24h
# my open orders
where[technician.user][_eq]=$USER.id&where[status][_nin]=COMPLETED,CANCELLED
# parts at or below their reorder point
where[quantity][_lte]=$ROW.reorder_point
$INTERVAL.7d always means "seven days ago from now". It isn't an interval value you can add to a column. Inside _in and _nin only literal values and $USER.* are accepted.
Limits
To protect the database, a query's size is bounded. Each limit is a setting:
| Limit | Default | Error |
|---|---|---|
| query string length | 32 KB | QUERY_STRING_TOO_LARGE |
| top-level parameters | 200 | QUERY_PARAMETER_LIMIT_EXCEEDED |
| conditions, fields, sorts and relation steps, counted together | 200 | QUERY_COMPLEXITY_LIMIT_EXCEEDED |
nesting of _and/_or/_not | 10 | QUERY_DEPTH_LIMIT_EXCEEDED |
| relation steps in a path | 3 | QUERY_DEPTH_LIMIT_EXCEEDED |
An administrator can turn off _regex, the containment operators, having and count per instance (query.allow_*), for large tables without the right indexes. A request using one then gets 400 QUERY_FEATURE_DISABLED.
The SQL it runs
This is the SQL TotalBase sent to PostgreSQL for the example above, as PostgreSQL's own log recorded it - ids shortened, long column lists cut with …. Every value travels as a parameter ($1), never inside the SQL text. "Urgent or overdue, in the Salta branch":
GET /api/public/table/work_orders?fields=number&where[_or][0][priority][_eq]=URGENT&where[_or][1][due_at][_lt]=$CURRENT_TIMESTAMP&where[site.branch.code][_eq]=SAL
SELECT t0."number" AS "number"
FROM "public"."work_orders" AS t0
LEFT JOIN "public"."sites" AS t1 ON t1."id" = t0."site"
LEFT JOIN "public"."branches" AS t2 ON t2."id" = t1."branch" -- site.branch.code, two steps
WHERE ((t0."priority" = $1 OR t0."due_at" < CURRENT_TIMESTAMP) AND t2."code" = $2)
ORDER BY t0."id" ASC
LIMIT $3 OFFSET $4; -- $1 = 'URGENT', $2 = 'SAL'
$CURRENT_TIMESTAMP becomes PostgreSQL's own CURRENT_TIMESTAMP, and $PERIOD.this_month.from becomes date_trunc('month', CURRENT_TIMESTAMP): PostgreSQL computes them, in the session's time zone, never the client.
Next steps
- Aggregating.
- Row filters: the same syntax in permissions.