
What Is GROQ? Sanity's Query Language Explained
GROQ (Graph-Relational Object Queries) is the query language Sanity uses to read content from the Content Lake. A GROQ query starts with *, meaning every document in the dataset, narrows it with a filter in square brackets such as [_type == "post"], and shapes the result with a projection in curly braces such as { title, "slug": slug.current }. It can also follow references with ->, sort with order(), slice results with ranges like [0...10] and call functions such as count() and coalesce(). In one request you get exactly the fields you asked for, in the shape you want, including data joined from related documents.
If you're coming from SQL or GraphQL, GROQ looks unusual at first, but it's small and consistent once the core pieces click. It's the main way you'll fetch content from Sanity, whether in a Next.js page, a script or the Studio itself. This article explains what GROQ is, the anatomy of a query, how filters, projections, references and ordering work, where you run queries, how parameters and perspectives fit in, and how GROQ compares with the alternatives.
Where GROQ Comes From
Sanity created GROQ to query schemaless JSON documents, and published an open specification for it in 2019. There's also an open-source JavaScript implementation, groq-js, so GROQ isn't strictly tied to Sanity's hosted backend, although the Content Lake is where most people use it.
Three design ideas explain most of the language:
- Documents are JSON: there are no tables. Every document has an
_idand a_type, and everything else is the structure your schema defines. - Queries are pipelines: you start with a set of documents and pass them through filters, ordering, slicing and projections, left to right.
- Joins are built in: references are followed with a single operator, so you don't need a separate query per related document.
The Anatomy of a Query
Here's a typical query, followed by what each part does:
*[_type == "post" && defined(slug.current)] | order(publishedAt desc) [0...5] {
title,
"slug": slug.current,
publishedAt,
"author": author->name
}
| Part | Name | What it does |
|---|---|---|
* | Everything | All documents in the dataset |
[_type == "post" && ...] | Filter | Keeps documents where the condition is true |
order(publishedAt desc) | Ordering | Sorts the results, newest first |
[0...5] | Slice | Takes the first five results |
{ ... } | Projection | Chooses and renames the fields returned |
author->name | Dereference | Follows the reference and returns the author's name |
The result is a JSON array:
[
{
"title": "What Is GROQ?",
"slug": "what-is-groq",
"publishedAt": "2026-11-04T00:00:00Z",
"author": "Sajjad"
}
]
Read the query left to right and you can predict its output. That's the core skill.
Filters
A filter is a boolean expression inside square brackets. Each document is tested against it, and only those that match are kept.
// All posts
*[_type == "post"]
// Posts in a category, published in the past
*[_type == "post" && category->slug.current == "sanity" && publishedAt < now()]
// Posts with a specific tag
*[_type == "post" && "groq" in tags]
// Posts that have an image
*[_type == "post" && defined(mainImage.asset)]
// Title search
*[_type == "post" && title match "groq*"]
The operators are familiar: ==, !=, <, >, <=, >=, &&, || and !. in checks membership in an array, and match does simple text matching. Always filter on _type first; it makes intent clear and lets the backend narrow the search quickly. How to filter and sort data with GROQ goes much deeper.
Projections
A projection controls the shape of each result. Without one, you get the whole document, which is usually more than you need.
*[_type == "post"]{
_id,
title,
"slug": slug.current,
"plainText": pt::text(body),
"readingTime": round(length(pt::text(body)) / 5 / 200),
"hasImage": defined(mainImage)
}
Things to notice:
- Plain fields like
titleare returned as they are. - Renamed fields use
"newName": expression, so"slug": slug.currentflattens the slug object to a string. - Computed fields can be any expression, including function calls like
pt::text(), which turns Portable Text into plain text. - The ellipsis
...includes all fields, and you can add or override fields after it:{ ..., "slug": slug.current }.
Projections are covered in detail in using projections in GROQ queries.
Following References
References are where GROQ earns its keep. A reference field stores an object with a _ref pointing to another document's ID. The -> operator follows it:
*[_type == "post"][0]{
title,
author->{ name, "avatar": image.asset->url },
categories[]->{ title, "slug": slug.current }
}
- author-> replaces the reference with the author document, then the projection picks fields from it.
- image.asset->url follows the image's asset reference to get its URL.
- categories[]-> follows every reference in an array.
You can also query in the other direction. references() finds documents that point to a given one:
*[_type == "author" && slug.current == $slug][0]{
name,
"posts": *[_type == "post" && references(^._id)]{ title }
}
The ^ operator refers to the enclosing document, here the author. The full story is in how to dereference documents in GROQ.
Ordering and Slicing
order() sorts by one or more fields, and slicing picks a range:
*[_type == "post"] | order(featured desc, publishedAt desc) [0...10]
[0...10]uses three dots, which excludes the end: items 0 to 9.[0..9]uses two dots, which includes the end: also items 0 to 9.[0]returns a single document instead of an array, ornullif there are no matches.
The [0] form is what you'll use for detail pages. Ranges are also the basis of pagination.
Functions
GROQ includes a set of built-in functions. A few you'll use constantly:
| Function | Example | Returns |
|---|---|---|
count() | count(*[_type == "post"]) | The number of items |
defined() | defined(mainImage) | Whether a value is not null |
coalesce() | coalesce(seoTitle, title) | The first non-null value |
now() | publishedAt < now() | The current time as a string |
select() | select(price > 100 => "premium", "standard") | A value based on conditions |
pt::text() | pt::text(body) | Plain text from Portable Text |
round() | round(rating, 1) | A rounded number |
You can also return several queries in one request by putting them in an object:
{
"posts": *[_type == "post"] | order(publishedAt desc) [0...5]{ title },
"total": count(*[_type == "post"]),
"settings": *[_id == "siteSettings"][0]{ title }
}
That's a single round trip for a whole page's data.
Where You Run GROQ
In the Studio With Vision
The Vision tool adds a GROQ playground to your Studio. Add it to your config:
// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { visionTool } from '@sanity/vision'
import { schemaTypes } from './schemaTypes'
export default defineConfig({
name: 'default',
projectId: 'your-project-id',
dataset: 'production',
plugins: [structureTool(), visionTool()],
schema: { types: schemaTypes },
})
It's the fastest way to experiment. Debugging GROQ queries with the Vision plugin covers its features.
In Your Code
With @sanity/client or next-sanity, pass the query and its parameters to fetch():
// src/sanity/client.ts
import { createClient } from 'next-sanity'
export const client = createClient({
projectId: 'your-project-id',
dataset: 'production',
apiVersion: '2026-11-01',
useCdn: true,
})
// src/app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { defineQuery } from 'next-sanity'
import { client } from '@/sanity/client'
const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{
title,
publishedAt,
"author": author->name
}`)
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await client.fetch(POST_QUERY, { slug })
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author}</p>
</article>
)
}
defineQuery lets Sanity TypeGen generate a result type for the query, so post is typed automatically once you've run npx sanity typegen generate.
Over HTTP
Under the hood, queries are sent to the query endpoint of your project's API. You can try one with curl:
curl "https://your-project-id.apicdn.sanity.io/v2026-11-01/data/query/production?query=*%5B_type%20%3D%3D%20%22post%22%5D%7Btitle%7D"
The apicdn host serves cached responses from Sanity's CDN, while api goes straight to the live API. The response wraps your data in a result property, alongside the query and timing information.
Parameters
Never build queries by concatenating user input. Use parameters, written with a $ prefix, and pass values separately:
await client.fetch(`*[_type == "post" && slug.current == $slug][0]`, { slug })
Parameters are safely encoded by the API, so a malicious value can't change the query's meaning. They also make queries reusable and easier to cache.
Perspectives and API Versions
Two settings on the client affect what a query returns:
- apiVersion: a date string such as
'2026-11-01'. It pins the API behaviour, so future changes don't break your queries. Always set it explicitly. - perspective: which version of documents to query.
publishedreturns only published content,draftstreats drafts as if they were published (useful for previews), andrawreturns everything, including drafts and release versions, for authenticated requests.
Since API version 2025-02-19, the default perspective is published. Earlier API versions defaulted to raw, which meant drafts could appear in authenticated queries unless you filtered them out. If you're upgrading an older project, check queries that relied on that behaviour.
GROQ vs GraphQL and SQL
GROQ isn't the only way to query Sanity. The platform can also deploy a GraphQL API generated from your schema. The short version:
| GROQ | GraphQL | SQL | |
|---|---|---|---|
| Data model | JSON documents | Typed graph | Tables |
| Schema needed to query | No | Yes, deployed separately | Yes |
| Joins | -> and subqueries | Nested fields | JOIN |
| Reshaping output | Any expression in projections | Limited to schema fields | SELECT expressions |
| Typical Sanity usage | Default, used everywhere | Optional, for GraphQL-based stacks | Not applicable |
GROQ's big advantage is that it queries the data exactly as stored, with no schema deployment step, and lets you compute and reshape results freely. The full comparison is in GROQ vs GraphQL.
FAQ: GROQ
GROQ stands for Graph-Relational Object Queries. It's the query language Sanity created for querying collections of JSON documents, with built-in support for following references between them.
GROQ has an open specification and an open-source JavaScript implementation called groq-js, so it can be used outside Sanity. In practice, it's mostly used with Sanity's Content Lake.
The asterisk means every document in the dataset. Queries usually follow it with a filter in square brackets, such as [_type == "post"], to narrow the set down.
Three dots exclude the end of the range and two dots include it, so both return the first ten items. A single index like [0] returns one item instead of an array.
Use the arrow operator, written as a hyphen followed by a greater-than sign, after the reference field, for example author->name. For arrays of references, add square brackets first, as in categories[]->.
No. GROQ is Sanity's primary query language and works without any extra setup. The GraphQL API is optional and needs to be deployed from your schema before you can use it.
Add the Vision tool to your Studio with visionTool() from @sanity/vision. It lets you run queries against your dataset, pass parameters and inspect the results.
Conclusion
GROQ is a compact language built for one job: getting exactly the content you need out of a set of JSON documents. A star selects documents, a filter narrows them, order() and slices arrange them, a projection shapes them, and -> pulls in related documents, all in one readable pipeline and one request.
Learn those five pieces, use parameters for every dynamic value, set an explicit apiVersion and be aware of perspectives. From there, the rest of GROQ is a set of functions and patterns you can pick up as you need them, starting with the GROQ cheat sheet of essential queries.


