Post
Running GROQ on Postgres without writing a query compiler
Full GROQ semantics from day one, with the queries a Studio actually issues staying cheap: parse with groq-js, narrow with SQL, evaluate in JavaScript.

GROQ is a big language. It has projections, joins, functions, slices, ordering, conditionals and a type system that does not map cleanly onto SQL's. Implementing it as a GROQ-to-SQL compiler is the obvious idea and the wrong first move: every gap in such a compiler is a production query that returns wrong results, silently.
So this platform does something less clever and much safer.
- Parse the query with
groq-js— the same parser Sanity's own tooling uses — to get an AST. - Narrow. A planner walks the filter and lifts out the predicates Postgres can evaluate cheaply:
_typeequality andin,_idequality andin,defined(field), plain field equality,slug.currentequality, and the draft/published discrimination the perspective implies. It also liftsorderand a trailing slice when doing so cannot change the result. - Evaluate. Those predicates become a JSONB query against the
documentstable, and the rows that come back are streamed intogroq-js, which computes the whole expression. - Dereference in batches.
->resolves through a per-request DataLoader, so a projection over N documents costs one extra query rather than N.
The trade is explicit: correctness is total from the first day, and performance is incremental — every predicate added to the planner makes a class of query cheaper without changing what any query returns.
What "narrowing cannot change results" means
It is the condition on step 2, and it is stricter than it sounds. A planner may only push a predicate down when the rows it excludes could not have contributed to the answer. Lifting order is safe; lifting a [0] is safe; lifting the filter of a query whose projection dereferences into other documents is safe for the candidate set but does not narrow what the dereference may touch — which is why a dereferencing query's cache tags widen to the whole dataset rather than to the documents it named.
-- what a planner-narrowed lookup by slug actually runs
select id, data from documents
where project_id = $1 and dataset = $2
and is_draft = false
and data->>'_type' = 'post'
and data->'slug'->>'current' = $3There is an expression index on that last term, because it is the single most common frontend lookup there is.
The search case
Sanity v6 defaults to a search strategy that issues text::match with wildcards, phrases and negation, and searches Portable Text. Postgres already has the right primitive: each document row carries a search_text tsvector generated from its extracted plain text, with Portable Text blocks flattened. The planner pushes text::match down to a tsquery, and groq-js remains responsible for the final semantics — the same division of labour as everywhere else.
