Skip to content

Query syntax

View as Markdown

You can make complex queries on tables by using the generated table API and the query syntax:

GET /api/tables/tasks?filter[project_id][eq]=1&filter[status][in]=open,review&q=launch&sort=due_date,-id&page=1&limit=25

That request reads tasks in project 1, keeps rows whose status is open or review, applies the table’s search plan to the term launch, orders by due date ascending and then by ID descending, and returns the first 25 matches.

Generated list, CSV export, count, and lookup routes all share this same grammar. Each route then accepts its own non-filter parameters and rejects every parameter it does not own.

Every filter names a column and an operator:

filter[column][operator]=value

filter[status]=open omits the operator and is rejected. The column must be a real SQL column on the table, and a typo returns a 400 rather than an ignored parameter, so a malformed narrow query never becomes a valid broad one.

Operators fall into five groups. The group decides the shape the value takes on the URL.

GroupOperatorsValue shape
Equalityeq, neqone value
Orderinggt, gte, lt, lteone value
Substringcontains, startswith, endswithone text value
Membershipin, nincomma-separated list, at least one item
Presenceisthe literal null or notnull

eq and neq compare one value against the stored value and apply to every column kind. The value is parsed in the column’s kind first, so filter[due_date][eq]=2026-08-01 compares a calendar day and filter[is_billable][eq]=true compares a boolean.

OperatorMatchesExample
eqthe stored value equals the given valuefilter[status][eq]=open
neqthe stored value differs from the given valuefilter[status][neq]=done

A row whose column is NULL holds no value to compare, so it matches neither eq nor neq. Select those rows with is.

gt, gte, lt, lte compare one value in the column’s own ordering and apply to number, date, and timestamp columns. A text column returns op_not_applicable.

OperatorMatchesExample
gtgreater thanfilter[estimate_hours][gt]=8
gtegreater than or equalfilter[due_date][gte]=2026-08-01
ltless thanfilter[due_date][lt]=2026-09-01
lteless than or equalfilter[estimate_hours][lte]=40

Two conditions on one column bound a range. The pair above selects every task due in August 2026.

contains, startswith, and endswith match inside a text column. Every other kind returns op_not_applicable. A % or _ in the value is escaped and matches literally.

OperatorMatchesExample
containsthe value appears anywherefilter[title][contains]=launch
startswiththe stored value begins with itfilter[title][startswith]=Publish
endswiththe stored value ends with itfilter[code][endswith]=-2026

in and nin take a comma-separated list and apply to text, number, date, and timestamp columns. Each item is parsed in the column’s kind. A boolean column takes eq and neq instead.

OperatorMatchesExample
inthe stored value is one of the itemsfilter[status][in]=open,review
ninthe stored value is none of the itemsfilter[project_id][nin]=4,5

The list must be non-empty and contain no empty item: filter[status][in]= and filter[status][in]=open,,review both return bad_value. A comma inside a value has no escape; match such a value with eq.

is carries a polarity rather than a value, applies to every column kind, and is the only operator that reads a missing value.

ValueMatchesExample
nullthe column is NULLfilter[due_date][is]=null
notnullthe column holds a valuefilter[due_date][is]=notnull

Any other value returns bad_value.

A column’s declared kind decides which operators apply and how each value is parsed.

KindOperatorsValue format
texteq, neq, contains, startswith, endswith, in, nin, isthe string as written
numbereq, neq, gt, gte, lt, lte, in, nin, isa finite number, such as 8 or -2.5
booleaneq, neq, istrue or false
dateeq, neq, gt, gte, lt, lte, in, nin, isan ISO calendar day, 2026-08-01
timestampeq, neq, gt, gte, lt, lte, in, nin, isan ISO instant with an offset, 2026-08-01T09:00:00Z

An operator outside a kind’s row returns op_not_applicable. A value that does not parse in the kind returns bad_value.

A timestamp column compares canonical instants. A day-shaped value such as 2026-08-24 carries no time and no offset, so it returns bad_value on a timestamp column.

A day is a range of instants, and one condition expresses one comparison. Ask a day-shaped question by naming the pair of instants the day occupies in the workspace time zone, gte the first and lt the first instant of the next day:

GET /api/tables/tasks?filter[completed_at][gte]=2026-08-24T00:00:00%2B05:30&filter[completed_at][lt]=2026-08-25T00:00:00%2B05:30

A + inside a query value decodes as a space, so an offset is percent-encoded as %2B. The server normalizes each bound to canonical UTC before comparing. The generated filter UI and resolveDateRangeQueryBounds() both produce that pair from a calendar day and the workspace time zone.

A date column stores the day itself, compares calendar days directly, and takes 2026-08-24 as written.

The same filter key may appear more than once. Every value is preserved in order and becomes a separate AND condition:

GET /api/tables/tasks?filter[title][contains]=launch&filter[title][contains]=checklist

That request requires both substrings. Indexed forms such as filter[title][contains][0] are not accepted, and repeated keys are not collapsed to the last value. Dropping either condition would widen the result.

q runs the table’s server-side search plan and combines with filters using AND. The complete trimmed term is one case-insensitive literal substring; %, _, and \ do not become wildcards. An empty or whitespace-only q is treated as absent. A non-empty q on a table with search: false returns no_search_config.

List and CSV export accept this table search. Lookup runs its own search over display fields, and count accepts no search term.

sort takes a comma-separated list of column names, each optionally prefixed with - for descending order. sort=created_at,-id sorts by creation time ascending, then by ID descending. Each name must be a real SQL column, and anything else returns unknown_column. Sort applies to list and CSV export.

ParameterDefaultAccepted rangeNotes
page11 through MAX_PAGEone-based
limit501 through 1000rows in one page

Both values are strings on the URL and on typed-client input. The shared contract coerces and bounds them before table-dependent resolution. Repeating a singleton key such as page or limit is invalid. CSV export is unpaginated.

Lookup has two separate query modes, and one request uses exactly one of them. Keeping the modes apart lets a picker recover its selected values without accidentally broadening into search.

ModeParametersBounds
IDids=7,91 through 500 non-empty values; rejects q, fields, limit
Searchq, fields, limitfields names visible display fields; limit defaults to 50 and accepts at most 500

Lookup does not accept filter[column][operator] parameters.

Count accepts the canonical filters and an optional group_by=<column>.

ParameterWithout group_byWith group_by
Responseone totalone row per group value
orderinvalidasc or desc, by count; defaults to desc
limitinvalid1 through 1000 groups; defaults to 50

Ties sort by group value ascending, so repeated calls return groups in a stable order.

RouteFiltersqsortPaginationOwn parameters
GET /api/tables/<table>yestable searchyespage, limit
GET /api/tables/<table>/export.csvyestable searchyesunpaginated
GET /api/tables/<table>/_countyesnononogroup_by, order, limit
GET /api/tables/<table>/_lookupnodisplay-field searchnolimitids, fields

A query passes two boundaries. Contract-shape failures — a repeated singleton, a nonnumeric page, an out-of-range list or lookup limit, contradictory lookup modes, an unsupported count order, or a grouped limit above 1000 — return HTTP 400 BAD_REQUEST. Table-dependent failures come after that boundary and return HTTP 400 with one of these stable codes:

CodeCause
unknown_filter_shapea filter[...] key that is not filter[column][operator]
unknown_columna filter, sort, or group column that is not on the table
unknown_opan operator outside the supported set
op_not_applicablean operator that does not apply to the column’s kind
bad_valuea value that does not parse in the column’s kind, an empty in list or item, or an is polarity other than null or notnull
no_search_configa non-empty q on a table with search: false

This malformed filter omits its operator:

GET /api/tables/tasks?filter[status]=open

It returns:

{
"error": "Filter \"filter[status]\" must use filter[col][op]=value syntax",
"code": "unknown_filter_shape"
}

Callers must correct a rejected query. Dropping an invalid predicate and retrying can widen a list, export, or count.