Querying

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

  1. Parse with groq-js, the same parser Sanity's own tooling uses.
  2. Narrow. A planner walks the filter and lifts predicates Postgres can evaluate cheaply: _type equality and in, _id equality and in, defined(field), plain field equality, slug.current equality, and the draft discrimination the perspective implies. It can also lift order and a trailing slice when doing so cannot change the result.
  3. Evaluate. The surviving rows are streamed into groq-js, which computes the whole expression — projections, joins, functions, conditionals.
  4. 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

groq
*[_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"
}
groq
// 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

groq
*[_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::match ranking is Postgres', not Sanity's — see full-text search.