GROQ against Postgres
How a query is executed here: parsed with groq-js, narrowed by a SQL planner, evaluated completely — so full semantics are available from the first day.
GROQ here is GROQ. The syntax, the functions and the semantics are Sanity's, and the language reference on sanity.io applies unchanged — which is why this page is about execution rather than about syntax.
The four steps
- Parse with
groq-js, the same parser Sanity's own tooling uses. - Narrow. A planner walks the filter and lifts predicates Postgres can evaluate cheaply:
_typeequality andin,_idequality andin,defined(field), plain field equality,slug.currentequality, and the draft discrimination the perspective implies. It can also liftorderand a trailing slice when doing so cannot change the result. - Evaluate. The surviving rows are streamed into
groq-js, which computes the whole expression — projections, joins, functions, conditionals. - Dereference in batches.
->resolves through a per-request loader, so a projection over a hundred documents costs one extra query rather than a hundred.
The trade is explicit: correctness is total, performance is incremental. No query can silently return the wrong thing because a compiler did not understand it; a query the planner cannot narrow is slower, not wrong.
Things that work exactly as you expect
*[_type == "post"] | order(publishedAt desc)[0...10]{
title,
"slug": slug.current,
"author": author->{name, "avatar": avatar.asset->url},
"wordCount": count(body[].children[].text),
"isRecent": publishedAt > "2026-07-01"
}// Filters on nested values, joins, and a projection over an array of objects.
*[_type == "page" && count(sections[_type == "compatibilitySection"]) > 0]{
title,
"matrixCount": count(sections[_type == "compatibilitySection"])
}Parameters
*[_type == $type && slug.current == $slug][0]Always parameters, never string concatenation.
Where the sharp edges are
Ordering inside a projection
It is evaluated in JavaScript, so a query that orders a large dereferenced set does more work than one that orders the outer result. Fetching the inner set in a second query and grouping it in the frontend is usually faster, and always easier to cache.
A nested query shares the candidate set
Every *[...] in one query — however deeply nested — is evaluated against the same set of rows. The planner therefore pushes down the union of every filter it can see, and drops the row cap, so that a parent-with-children query returns the children it should. Keep a _type on the inner filter: one the planner cannot narrow widens the candidate set for the whole query. The documentation sidebar on this site is exactly that shape.
The rest, in short
- A dereferencing query widens its cache tags to the whole dataset, because the documents it reached through
->are not known before the query runs. text::matchranking is Postgres', not Sanity's — see full-text search.
