Type something to search...
What Is GROQ? Sanity's Query Language Explained

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 _id and 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
}
PartNameWhat it does
*EverythingAll documents in the dataset
[_type == "post" && ...]FilterKeeps documents where the condition is true
order(publishedAt desc)OrderingSorts the results, newest first
[0...5]SliceTakes the first five results
{ ... }ProjectionChooses and renames the fields returned
author->nameDereferenceFollows 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 title are returned as they are.
  • Renamed fields use "newName": expression, so "slug": slug.current flattens 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, or null if 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:

FunctionExampleReturns
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. published returns only published content, drafts treats drafts as if they were published (useful for previews), and raw returns 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:

GROQGraphQLSQL
Data modelJSON documentsTyped graphTables
Schema needed to queryNoYes, deployed separatelyYes
Joins-> and subqueriesNested fieldsJOIN
Reshaping outputAny expression in projectionsLimited to schema fieldsSELECT expressions
Typical Sanity usageDefault, used everywhereOptional, for GraphQL-based stacksNot 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.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper