Skip to main content
USA-Based Digital Agency

Headless CMS

GROQ for SQL Developers: A Practical Translation Guide

By George Shvaya · Updated July 2026 · 10 min read

GROQ — Graph-Relational Object Queries — is Sanity's open-source query language for JSON documents. If you know SQL, you already think in filters, projections, joins, and ordering, and GROQ expresses those same four ideas over document trees instead of tables: references replace foreign keys, and a projection replaces the SELECT list while also deciding the shape of the JSON you get back. That last clause is the part SQL has no equivalent for, and it is where most of the learning happens. Everything else translates closely enough that you can be writing useful queries within an hour.

This guide is written the way we onboard backend developers onto Sanity projects: start from the SQL you already write, translate it line by line, then name the three mental-model shifts that make the translations stop feeling like translations. It also says plainly what GROQ does not do, because discovering that halfway through a build is expensive.

Three Mental-Model Shifts

1. Documents, not rows

There are no tables. A Sanity dataset is one flat pool of JSON documents, each with a _type field, and every query starts by filtering that whole pool. So FROM posts becomes _type == "post", sitting in the same filter as your other conditions rather than in a separate clause. A document can nest arrays and objects arbitrarily deep, which means the thing you are filtering is a tree, and a "column" might be three levels down.

2. References, not foreign keys

A reference field in Sanity stores the target document's _id and is typed at the schema level, so the query engine already knows what it points at. Following it is the -> operator: author->name. There is no ON clause, no join type to pick, and no risk of accidentally producing a cartesian product. For an array of references you write categories[]->title, and to walk a relationship in the direction the field does not point, you use references() inside a subquery.

3. Projections shape the response

In SQL, the SELECT list picks columns and the result is always a flat table. In GROQ, the projection picks fields and defines the JSON structure returned: you can rename fields, nest objects, compute values, and embed entire subqueries as fields. The practical consequence is that a page query returns a payload your component can consume directly, rather than a result set your application code has to reassemble. Once that clicks, you stop writing GROQ that looks like SQL and start designing the response.

Eight Translations, Side by Side

Each pair below is a query you have almost certainly written in SQL, next to the GROQ that does the same job. Read the note under each one — the interesting information is usually in what changed, not in what stayed the same.

1. Filter, sort, and limit

SQL

SELECT *
FROM posts
WHERE published = true
ORDER BY date DESC
LIMIT 10;

GROQ

*[_type == "post" && published == true]
  | order(date desc)[0...10]

The leading * is "every document in the dataset"; the square brackets filter it. Because there are no tables, _type does the work that FROM does in SQL. Ordering is a pipe into order(), and the trailing slice replaces LIMIT — note that [0...10] is exclusive at the top, so it returns ten documents.

2. Choose your columns (projection)

SQL

SELECT id, title, slug
FROM posts
WHERE published = true;

GROQ

*[_type == "post" && published == true]{
  _id,
  title,
  "slug": slug.current
}

A projection is the SELECT list, written as a JSON object literal. The quoted key on the left renames the output field, which lets you flatten a nested value — slug is an object in Sanity, and slug.current is the string you actually want on the page.

3. Join a related record

SQL

SELECT p.title, a.name AS author
FROM posts p
JOIN authors a ON p.author_id = a.id;

GROQ

*[_type == "post"]{
  title,
  "author": author->name
}

This is the single biggest quality-of-life difference. A reference field already knows what it points at, so the -> operator follows it and you never write a join condition. There is no foreign key to remember and no ambiguity about which column matches which.

4. Join and keep several fields

SQL

SELECT p.title,
       a.name,
       a.bio
FROM posts p
JOIN authors a ON p.author_id = a.id
WHERE p.published = true;

GROQ

*[_type == "post" && published == true]{
  title,
  author->{
    name,
    bio
  }
}

Dereference into a nested projection when you want more than one field from the related document. The response nests author as an object, which is usually exactly what a component wants — no flat row to reassemble in application code.

5. Count rows

SQL

SELECT COUNT(*)
FROM posts
WHERE published = true;

GROQ

count(*[_type == "post" && published == true])

count() takes an array and returns its length, so you wrap the whole filtered set in it. The same function works on an array field inside a projection, for example "tagCount": count(tags).

6. Match a set of values

SQL

SELECT title
FROM posts
WHERE category IN ('seo', 'cms', 'performance');

GROQ

*[_type == "post"
  && category->slug.current in ["seo", "cms", "performance"]
]{ title }

The in operator reads the same as SQL, and it composes with dereferencing so you can filter on a field of a referenced document without a join clause. It also works in reverse: "featured" in tags asks whether an array field contains a value.

7. Filter on presence and pattern

SQL

SELECT title
FROM posts
WHERE excerpt IS NOT NULL
  AND title LIKE 'sanity%';

GROQ

*[_type == "post"
  && defined(excerpt)
  && title match "sanity*"
]{ title }

defined() is the IS NOT NULL test. match is a word-oriented text comparison rather than a character-level LIKE — it tokenizes the field and supports * as a wildcard, which makes it better for search boxes and worse for exact substring checks.

8. Filter inside an array, and pull children in one query

SQL

-- Two queries, or a join plus
-- application-side grouping
SELECT * FROM authors WHERE id = $1;
SELECT title FROM posts
WHERE author_id = $1
ORDER BY date DESC;

GROQ

*[_type == "author" && slug.current == $slug][0]{
  name,
  "activeLinks": links[isPublic == true],
  "posts": *[_type == "post"
    && references(^._id)
  ] | order(date desc)[0...5]{ title, date }
}

Two things happen here. links[isPublic == true] filters an array of objects in place, keeping the field but dropping the entries that fail. The posts subquery walks the relationship backwards using references(), with ^ meaning "the document one scope up" — the parent author. The trailing [0] on the outer filter returns a single object instead of a one-element array.

What GROQ Does That SQL Makes Painful

The eighth translation above is the honest argument for GROQ. In SQL, assembling an author page — the author record, their public links, their five most recent posts — is either multiple round trips or one join whose flat rows you regroup in application code. Both are fine; both are work you write, test, and maintain. In GROQ it is one query whose output already matches the shape of the page, because a subquery can live inside a projection and the parent scope is addressable with ^.

That difference compounds on content-heavy pages. A landing page pulling a hero, a service list with dereferenced icons, six testimonials filtered by tag, and a related-articles block is one GROQ query and one network request. The same page over a REST or SQL layer is typically several requests or a purpose-built endpoint per page — which is precisely the coupling headless architecture is supposed to remove. It is also why GROQ pairs well with static generation: one query per route, resolved at build time.

Two smaller wins matter more than they sound. Projections let you name things for the frontend at the query boundary, so components are not littered with slug.current access chains. And references() gives you reverse relationships without modeling them — you can ask "what points at this document?" on day one hundred without a schema migration, which is the kind of question that comes up constantly once content grows.

The Honest Tradeoffs

GROQ is a read language for content. It is not trying to be Postgres, and treating it as a general-purpose database interface is the fastest way to be disappointed by it.

What GROQ gives you that SQL does not:

  • One request returns the exact nested shape a page needs — no join-then-regroup step in application code
  • Reverse relationships are queryable without a schema change, because references() searches for pointers to a document
  • Renaming and computing fields in the projection keeps presentation logic out of your components
  • Parameterized queries ($slug, $id) are the normal way to write them, so injection is not a shape you can easily fall into

What SQL does that GROQ does not attempt:

  • Transactions: GROQ is read-only. Writes go through Sanity's mutation API, and there is no BEGIN/COMMIT you drive from a query
  • Aggregation depth: there is no GROUP BY or HAVING. You get count() and a math namespace (math::sum, math::avg, math::min, math::max), which covers totals but not grouped analytics
  • Set operations: no UNION, INTERSECT, or EXCEPT, and no window functions or CTEs
  • Query planning: no EXPLAIN, no indexes you define, no query hints — performance tuning means reshaping the query, not the storage

None of that is a defect; it is scope. The practical rule we apply when choosing a platform is that if a workload needs grouped analytics, multi-document transactional integrity, or a query planner you can reason about, that workload belongs in a real database and the CMS should hold content only. If your project is mostly the second kind of data — an existing SQL schema that must stay authoritative — a different tool may fit better, which is the tradeoff we walk through in Sanity vs Strapi and in our headless CMS development framework.

Where GROQ Actually Runs

GROQ queries execute against Sanity's Content Lake over an HTTP API, and in a Next.js project you almost never call that API by hand. The next-sanity client wraps it, so a query is a tagged string passed to client.fetch() with a parameters object, executed in a server component or during static generation. Parameters are the mechanism for anything user-supplied — never interpolate values into the query string.

Two tools shorten the learning curve considerably. The Vision plugin ships inside Sanity Studio and gives you a live query console against your real dataset, which is the fastest way to iterate on a projection. And because GROQ has a formal specification with open-source parsers, editor tooling and type generation exist — worth turning on early if your project is TypeScript, since generated result types catch the projection mistakes that are otherwise invisible until render.

Start with the official GROQ documentation for the function reference and the GROQ specification when you need precise semantics — operator precedence and scoping rules are much clearer there than in tutorials. If you are arriving from an existing site rather than a greenfield build, our WordPress to Sanity migration guide covers the modeling decisions that determine how clean your GROQ ends up being — badly modeled content produces awkward queries no matter how well you know the language.

Frequently Asked Questions

Is GROQ proprietary to Sanity?

The language is not proprietary — GROQ has a published open specification, and open-source parsers and evaluators exist for it in JavaScript, Go, and other languages. What is Sanity-specific is the execution environment: the production implementation you query against is the hosted Content Lake API. So the syntax you learn is portable knowledge, but running GROQ at scale against your content today means running it against Sanity.

Should I use GROQ or GraphQL with Sanity?

Sanity supports both, and the tradeoff is shaping power versus tooling familiarity. GROQ lets you define the exact response shape per query, including subqueries and computed fields, without predefining anything. GraphQL gives you a typed schema, introspection, and the broader ecosystem of GraphQL clients, but the Sanity GraphQL API must be deployed and it exposes less of GROQ's filtering and projection flexibility. Most teams we work with use GROQ for page queries and reach for GraphQL only when an existing GraphQL toolchain is already in place.

Do I need to learn GROQ to use Sanity?

To build a frontend on Sanity, effectively yes — GROQ is how content gets out of the Content Lake, and next-sanity wraps GROQ queries rather than replacing them. Content editors never see it. If you are evaluating Sanity as a stakeholder rather than a developer, GROQ is an implementation detail; if you are the person writing the data layer, it is the main thing you will learn.

How steep is the GROQ learning curve for a SQL developer?

Honestly: an afternoon to be productive, a week or two to stop fighting it. Filters, ordering, and slicing map almost directly from SQL and feel natural immediately. The parts that take longer are projections that reshape nested output, the dereference operator, and the parent scope operator used inside subqueries — those have no SQL equivalent to lean on. The most common early mistake is writing GROQ as if it returned flat rows instead of designing the response shape the page actually needs.

Building on Sanity?

We model content, write the GROQ layer, and ship the frontend — with the query layer documented so your team can maintain it.

Talk to a Sanity Developer
Get Free Growth Plan