Type something to search...
Using GROQ Functions: count(), defined(), and More

Using GROQ Functions: count(), defined(), and More

GROQ functions are built-in helpers you call inside filters and projections to test, transform and compute values. The ones you'll use most are count() to count items in an array or query, defined() to check that a field has a value, coalesce() to pick the first non-null value, select() for if/else logic, and references() to find documents that point at another. Beyond those, namespaced functions such as string::startsWith(), array::unique(), math::sum() and pt::text() handle strings, arrays, numbers and Portable Text.

Functions are what let a GROQ query do work that would otherwise happen in your application code: counting comments, falling back to a default image, labelling products as low stock, or turning rich text into a plain excerpt. This guide groups the functions by what you'll use them for, with a realistic example for each, and finishes with the pipe functions order() and score() and a few rules about where functions can be used.

How Functions Work in GROQ

Functions are called with parentheses and can appear almost anywhere an expression is allowed: in filters, in projections and at the top level.

*[_type == "post" && defined(mainImage)] {
  title,
  "comments": count(*[_type == "comment" && references(^._id)])
}

There are two kinds:

KindSyntaxExamples
Regular functionsfn(args)count(), defined(), coalesce()
Namespaced functionsnamespace::fn(args)string::split(), array::join(), math::avg()
Pipe functions| fn(args)order(), score()

Most functions return null when given the wrong type of input, rather than throwing an error. That keeps queries resilient, but it also means a typo can quietly produce null, so check results in Vision as you go.

count()

count() returns the number of items in an array.

// Number of posts
count(*[_type == "post"])

// Number of tags on each post
*[_type == "post"] { title, "tagCount": count(tags) }

// Posts per category
*[_type == "category"] {
  title,
  "postCount": count(*[_type == "post" && references(^._id)])
} | order(postCount desc)

It's also useful in filters:

// Posts with at least three tags
*[_type == "post" && count(tags) >= 3]

Note that count() on a missing field returns null, not 0, so count(tags) == 0 won't match documents where tags was never set. Combine it with defined() when that matters, or wrap it as coalesce(count(tags), 0) in projections. For counting results alongside a paginated list, see pagination with GROQ.

defined()

defined() returns true if a value is not null. It's the standard way to check whether a field exists.

// Only posts with a slug, so links never break
*[_type == "post" && defined(slug.current)]

// Posts missing an SEO description
*[_type == "post" && !defined(seo.description)] { _id, title }

// Flag in a projection
*[_type == "post"] { title, "hasImage": defined(mainImage.asset) }

The second example is a handy editorial report: run it in Vision to find every post that still needs a meta description.

coalesce()

coalesce() takes any number of arguments and returns the first one that isn't null.

*[_type == "post"] {
  "heading": coalesce(seo.title, title),
  "summary": coalesce(excerpt, seo.description, "Read the full post"),
  "image": coalesce(mainImage, author->image, *[_type == "siteSettings"][0].defaultImage)
}

It's ideal for fallbacks: a custom SEO title falling back to the main title, or a post image falling back to the author's photo and then a site-wide default. It's also useful for multilingual fields, such as coalesce(title[$lang], title.en).

select()

select() is GROQ's if/else. It takes condition => value pairs and returns the value for the first condition that's true. A final value without a condition is the default.

*[_type == "product"] {
  title,
  stock,
  "availability": select(
    stock == 0 => "Sold out",
    stock < 5 => "Only a few left",
    "In stock"
  ),
  "priceBand": select(
    price >= 100 => "premium",
    price >= 30 => "standard",
    "budget"
  )
}

If no condition matches and there's no default, select() returns null.

references()

references() returns true if the current document contains a reference to the given ID, anywhere in the document. It accepts a single ID or an array of IDs.

// Posts that reference a specific author
*[_type == "post" && references($authorId)]

// Posts that reference any of several categories
*[_type == "post" && references($categoryIds)]

// Inside a projection, use ^ to refer to the outer document
*[_type == "author"] {
  name,
  "posts": *[_type == "post" && references(^._id)] { title }
}

references() is usually faster than filtering with -> because it uses a reference index. The dereferencing guide compares the two approaches.

Date and Time Functions

FunctionWhat it does
now()Current UTC time as an ISO string, the same throughout one query
dateTime(str)Converts an ISO string to a date-time for arithmetic and comparison
// Published posts only
*[_type == "post" && publishedAt <= now()]

// Events in the next 7 days
*[_type == "event" && dateTime(startsAt) > dateTime(now())
  && dateTime(startsAt) < dateTime(now()) + 60*60*24*7]

// Days since publishing
*[_type == "post"] {
  title,
  "daysOld": round((dateTime(now()) - dateTime(publishedAt)) / 86400)
}

Subtracting two date-times returns seconds, and adding a number adds seconds. Be careful when caching: a query using now() returns different results over time, so a long CDN cache can show scheduled posts later than you expect.

String Functions

FunctionExampleResult
lower(str)lower("GROQ")"groq"
upper(str)upper("groq")"GROQ"
string(value)string(42)"42"
length(str)length("Sanity")6
string::startsWith(str, prefix)string::startsWith("groq-tips", "groq")true
string::split(str, sep)string::split("a,b,c", ",")["a", "b", "c"]

Practical uses:

// Case-insensitive alphabetical order
*[_type == "author"] | order(lower(name) asc) { name }

// Slugs in a section of the site
*[_type == "page" && string::startsWith(slug.current, "docs/")]

// Year from an ISO date
*[_type == "post"] { title, "year": string::split(publishedAt, "-")[0] }

string::startsWith() is the right tool for slugs, IDs and other identifiers. Use match for natural-language text instead, as explained in full-text search in GROQ.

Array Functions

FunctionWhat it does
array::compact(arr)Removes null values
array::unique(arr)Removes duplicates (for simple values)
array::join(arr, sep)Joins values into a string
array::intersects(a, b)true if the arrays share any value
length(arr)Number of items, like count()
// Every tag used on the site, without duplicates
array::unique(*[_type == "post"].tags[])

// Category titles as a comma-separated string, ignoring missing references
*[_type == "post"] {
  title,
  "categoryList": array::join(array::compact(categories[]->title), ", ")
}

// Posts sharing a tag with a given list
*[_type == "post" && array::intersects(tags, $tags)]

The first query builds a tag cloud in one request.

Maths Functions

FunctionWhat it does
math::sum(arr)Total of the numbers
math::avg(arr)Mean, ignoring null
math::min(arr)Smallest number
math::max(arr)Largest number
round(num, places)Rounds, optionally to a number of decimal places
*[_type == "product" && slug.current == $slug][0] {
  title,
  "reviewCount": count(*[_type == "review" && references(^._id)]),
  "averageRating": round(math::avg(*[_type == "review" && references(^._id)].rating), 1),
  "priceRange": {
    "from": math::min(variants[].price),
    "to": math::max(variants[].price)
  }
}

Portable Text: pt::text()

pt::text() converts Portable Text blocks into plain text, which is useful for excerpts, reading times and search.

*[_type == "post"] {
  title,
  "excerpt": array::join(string::split(pt::text(body), "")[0..159], "") + "...",
  "wordCount": length(string::split(pt::text(body), " "))
}

It only reads text from regular blocks at the top level of the array, so custom blocks such as code or images are ignored.

Pipe Functions: order() and score()

Pipe functions take the whole result array and return a new one. They're written after a |.

// Sort
*[_type == "post"] | order(publishedAt desc)

// Rank by relevance
*[_type == "post"]
  | score(boost(title match $term, 3), pt::text(body) match $term)
  | order(_score desc)
  { title, _score }
  [_score > 0]

score() adds a _score to each result based on how many expressions match, and boost() weights an expression more heavily. Documents that match nothing get a score of 0 but aren't removed, which is why the example filters on _score > 0 at the end. Sorting is covered in depth in filtering and sorting with GROQ.

Other Useful Functions

  • identity(): returns the ID of the user making the request, useful in Studio contexts such as "documents I created".
  • path(): builds a path for matching IDs, as in _id in path("drafts.**") to find drafts when querying with the raw perspective.
  • sanity::projectId() and sanity::dataset(): return the current project and dataset, handy when building asset URLs in a query.
  • geo:: functions: geo::distance(), geo::contains() and geo::intersects() work with geopoint fields.
  • delta:: functions and before() / after(): available in webhook and Sanity Functions filters, to react only when specific fields change, such as delta::changedAny(slug).

Using Functions From Your Code

Functions run on Sanity's side, so your code just sends the query:

// src/app/shop/[slug]/page.tsx (excerpt)
import { defineQuery } from 'next-sanity'
import { client } from '@/sanity/client'

const PRODUCT_QUERY = defineQuery(`*[_type == "product" && slug.current == $slug][0] {
  title,
  "availability": select(stock == 0 => "Sold out", stock < 5 => "Only a few left", "In stock"),
  "averageRating": round(math::avg(*[_type == "review" && references(^._id)].rating), 1)
}`)

export async function getProduct(slug: string) {
  return client.fetch(PRODUCT_QUERY, { slug })
}

Run npx sanity typegen generate after changing a query like this. TypeGen infers result types for many functions, and where it can't narrow a value it falls back to a broader type, so check the generated types for computed fields.


FAQ: GROQ Functions

count() counts items in an array and is the usual choice for query results and array fields. length() works on both arrays and strings, so it can also return the number of characters in a string.

Use defined(field) to check that it has a value and !defined(field) to find documents where it's missing or null. For arrays, also check count(field) > 0, since an empty array is still defined.

It returns the first argument that isn't null. Use it for fallbacks, such as an SEO title falling back to the main title, or a post image falling back to a default image from a settings document.

Yes, with select(). Pass condition and value pairs separated by an arrow, plus an optional final default value. GROQ returns the value for the first condition that's true.

Most GROQ functions return null when given input of the wrong type or a missing field, instead of raising an error. Check that the field exists and has the type you expect, for example that a date is a valid ISO string before passing it to dateTime().

GROQ supports defining custom functions at the start of a query with the fn keyword, which helps reuse a projection across several parts of a query. They're limited compared with functions in a programming language, so most logic still belongs in the built-in functions or your application code.


Conclusion

GROQ functions move logic from your application into the query. count(), defined(), coalesce(), select() and references() cover most everyday needs, while the string, array, maths, date and Portable Text helpers handle the rest, from tag clouds and average ratings to excerpts and reading times.

Use them to send your front end data that's ready to render, test them in Vision with real documents, and remember that most functions return null rather than failing loudly. With the patterns in this guide and the GROQ cheat sheet to hand, you can answer most content questions in a single query.

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