Skip to content
Talk to our solutions team

JSON syntax

The JSON syntax is isomorphic with the pipe syntax: every pipe form has a JSON equivalent, every JSON form has a pipe equivalent, and both decode into the same AST before a single compiler turns it into SQL. Nothing is expressible in one and not the other, with the small set of exceptions listed under Gaps.

{
"from": "orders",
"where": { "status": "paid", "total": { "$gt": 100 } },
"include": { "user": { "select": ["name", "email"] } },
"orderBy": ["-created_at"],
"limit": 10
}
orders | status = "paid", total > 100 | +user(name, email) | -created_at | 10

Same tree, same SQL, same access rules.

Use JSON whenUse the pipe form when
The query is assembled by code — a UI filter panel, a saved view, a generated reportThe query is written by a person
It is stored, versioned or diffed as dataIt appears inline in a script
A model produces it — a JSON schema constrains generation far better than a grammarBrevity matters
Fragments are merged from several sources

The deciding question is who writes it. Building a pipe string by concatenation invites exactly the injection problem this DSL exists to avoid; building a JSON object is ordinary data manipulation.

The decoder picks the operation from the first key it recognises, in this order:

Key presentOperation
fromQuery
countCount query
search + inMulti-entity search
queriesMulti-query
createCreate mutation
updateUpdate mutation
upsertUpsert mutation
deleteDelete mutation
transitionStateflow transition
transactionTransaction
operationsOperation batch

An object with none of these is refused: unknown operation type in JSON. An object with two of them resolves by that order rather than erroring, so do not send both from and create and expect a complaint — send one.

KeyTypeMeaning
fromstring, or {"$ref": "..."}The entity. Required
idstringFetch by primary key. Compiles to <pk> = $1
whereobjectThe filter. See Operators
selectarray or objectProjection. See Projection
includeobject or arrayRelation expansion
orderByarray or objectSorting
groupByarray of stringsGrouping
havingobjectPost-aggregation filter, same grammar as where
limitnumberRow cap
offsetnumberOffset pagination
page, pageSizenumberPage pagination
after, beforestringCursor pagination
traverseobjectHierarchy traversal
depthnumber or objectTraversal depth
directionstringancestors or descendants
withobjectCTEs and subqueries
datastorestringTarget a non-default datastore
optionsobjectSee Options

Field conditions live under where. A bare value means equality; an object selects an operator.

{ "where": { "status": "paid" } }
{ "where": { "total": { "$gt": 100 } } }
OperatorMeaningExample
$eqEqual{"status": {"$eq": "paid"}}
$neNot equal{"status": {"$ne": "void"}}
$gt, $gteGreater than, or equal{"total": {"$gte": 100}}
$lt, $lteLess than, or equal{"total": {"$lt": 1000}}
$inMember of a set{"status": {"$in": ["paid", "shipped"]}}
$ninNot a member{"status": {"$nin": ["void", "draft"]}}
$likePattern, case-sensitive{"name": {"$like": "Ada%"}}
$nlikePattern, negated{"name": {"$nlike": "test%"}}
$ilikePattern, case-insensitive{"email": {"$ilike": "%@example.com"}}
$containsSubstring or collection membership{"tags": {"$contains": "urgent"}}
$betweenInclusive range, two-element array{"total": {"$between": [100, 500]}}
$nullIs null, or is not{"deleted_at": {"$null": true}}
$existsField present{"metadata": {"$exists": true}}

A bare value is equality, and that is the whole shorthand. There is no implicit $in for an array — {"status": ["paid","shipped"]} is not a set membership, it is an equality against an array value, which is almost certainly not what you meant. Write $in.

Several fields in one object are AND-ed:

{ "where": { "status": "paid", "total": { "$gt": 100 } } }

For anything else, name the connective:

KeyTakesMeaning
$andarray of expressionsAll must hold
$orarray of expressionsAt least one
$notone expressionNegation
{
"where": {
"$or": [
{ "status": "paid" },
{ "$and": [
{ "status": "pending" },
{ "created_at": { "$lt": { "$now": "-7d" } } }
] }
]
}
}

Nesting is unlimited, and $and / $or / $not may appear at any depth.

JSONBecomes
"text"String
42, 3.14Number
true, falseBoolean
nullNull — prefer {"$null": true} for the predicate
["a","b"]Array, for $in, $nin, $between
{"$now": "..."}Relative time. See below
{"$ref": "..."}A reference to another query’s result
{ "created_at": { "$gte": { "$now": "-7d" } } }
UnitMeaning
sseconds
mminutes
hhours
ddays
wweeks
Mmonths — capital M
yyears

m is minutes and M is months, which is the one to get right. The pipe syntax’s named anchors — today, yesterday, startofweek, startofmonth, startofyear — are available in the same position.

{ "select": ["id", "total", "status"] }

With aggregation, use the object form:

{
"from": "orders",
"groupBy": ["status"],
"select": { "status": true, "n": { "$count": "*" }, "revenue": { "$sum": "total" } }
}

Omitting select projects the entity’s declared fields in canonical order. It never compiles to SELECT * — which is what makes a schema change visible as a diff rather than as a surprise in a response body.

{ "include": { "user": { "select": ["name", "email"] } } }
{ "include": ["user", "line_items"] }

An include issues a separate query per relation, not a join. That is deliberate — it keeps the row count of the parent query honest — and it is why an include over a large parent set costs what it costs. The nested object accepts select, where, orderBy and limit, applied to the relation.

{ "orderBy": ["-created_at", "name"] }
{ "orderBy": [{ "field": "created_at", "dir": "desc" }] }

A leading - is descending, a leading ^ or nothing is ascending.

PaginationKeysNotes
Offsetlimit, offsetSimple, and drifts under concurrent writes
Pagepage, pageSizeParses; see the pipe page on what compiles
Cursorafter, beforeStable under writes. Prefer for anything user-facing
{
"from": "employees",
"traverse": { "direction": "descendants", "via": "manager_id" },
"depth": { "$lte": 3 }
}

direction is ancestors or descendants; via names the self-referencing field. depth constrains the walk and applies only to a traversal.

{ "options": { "stats": true, "datastore": "reporting", "timeout": 5000 } }
OptionEffect
statsReturn timing and the compiled statement alongside the rows
datastoreRun against a named datastore
timeoutMilliseconds
cacheCache directives
localeLocale for localised fields
unmask, untokenizeReveal protected values, subject to the caller’s rights
versionRead a specific version, where versioning is on
deletedInclude soft-deleted rows
metaInclude record metadata

unmask and untokenize are requests, not grants — the caller still has to be entitled to the underlying value. See Compliances.

{ "create": "orders", "data": { "status": "draft", "total": 0 }, "returning": ["id", "created_at"] }
{ "update": "orders", "id": "abc-123", "data": { "status": "paid" }, "returning": ["id", "status"] }
{ "update": "orders", "where": { "status": "draft" }, "data": { "status": "void" } }
{ "delete": "orders", "id": "abc-123", "cascade": true }
{ "upsert": "orders", "on": ["external_id"], "data": { "external_id": "X-1", "total": 250 } }
KeyApplies toMeaning
datacreate, update, upsertThe values
idupdate, delete, transitionTarget one row by primary key
whereupdate, deleteTarget a set
onupsertConflict key
returningallColumns to return
cascadedeleteFollow declared cascades
purgedeleteHard delete rather than soft

An update by id does not put the primary key in the SET clause, and a body whose id contradicts the addressed row is refused rather than silently preferring one. Both matter when a client round-trips a record it just read.

{
"transaction": [
{ "create": "orders", "data": { "total": 0 } },
{ "create": "line_items", "data": { "order_id": { "$ref": "0.id" }, "sku": "A-1" } }
]
}

$ref addresses an earlier operation’s result by index and field, which is what makes a create-then-reference sequence possible without a round trip. operations is the non-transactional form of the same shape — each runs, and a failure does not roll back the others.

Isomorphic in principle, and these are the edges where it currently is not:

Pipe formJSON
matchNo $match — use $like / $ilike
nilikeNo $nilike — express as {"$not": {"field": {"$ilike": "..."}}}
| search "term"search decodes, and like the pipe form it does not compile
Comments (--)JSON has none. Carry intent in a sibling key your code ignores

The decoder reports the path it was walking, which is worth reading rather than guessing at:

invalid where clause: field "total": unknown operator "$gtt"

An unknown $-operator is an error, not an ignored key. That is the opposite of the shorthand trap in the operator table above — a misspelled operator fails loudly, while a misused shape ({"status": ["paid","shipped"]}) is valid JSON meaning something you did not intend.