
How to Filter and Sort Data With GROQ
To filter and sort data with GROQ, put a condition inside square brackets after the *, such as *[_type == "post" && featured == true], then pipe the results into order(), for example | order(publishedAt desc). Filters keep every document where the expression is true and can combine conditions with &&, || and !. order() accepts one or more fields, each followed by asc or desc, and runs before any slice, so *[_type == "post"] | order(publishedAt desc) [0...5] returns the five newest posts.
Filtering and sorting are the parts of a GROQ query that decide which documents come back and in what order. Getting them right keeps your pages correct and your queries fast, because the Content Lake can use filters on common fields to avoid scanning everything. This guide covers comparison and logical operators, filtering on dates, arrays, references and missing fields, sorting on single and multiple keys, case-insensitive sorting, and the mistakes that most often lead to empty or wrongly ordered results.
How Filters Work
A filter is an expression evaluated once for every document. If it returns true, the document is kept. If it returns false or null, the document is dropped.
*[_type == "post"]
That last point about null matters. In GROQ, comparing a missing field returns null, not false. So *[rating > 3] silently skips every document without a rating field, which is usually what you want, but it also means *[!(rating > 3)] doesn't return them either, because !null is still null. Use defined() when you need to be explicit about missing fields.
Always Start With the Type
Put _type == "..." first in almost every filter. It narrows the search to one document type, makes the intent clear, and stops a query for slug.current == $slug accidentally matching a page, a product and a post that share the same slug.
Comparison Operators
| Operator | Meaning | Example |
|---|---|---|
== | Equal | category == "news" |
!= | Not equal | status != "archived" |
> / >= | Greater than (or equal) | price >= 100 |
< / <= | Less than (or equal) | stock < 5 |
in | Value is in an array or range | category in ["news", "guides"] |
match | Word or prefix text match | title match "sanity*" |
Comparisons are type-sensitive. A string "5" is not equal to the number 5, and comparing a string with a number using > returns null. If an editor can type a number into a string field, fix it in the schema rather than in the query.
Ranges With in
in also works with ranges, which reads nicely for numbers:
*[_type == "product" && price in 10..50]
Two dots include both ends, so this keeps prices from 10 to 50 inclusive.
Logical Operators
Combine conditions with &&, || and !. Use brackets whenever you mix && and ||, because && binds more tightly:
// Posts that are featured OR in the news category, and published
*[_type == "post" && (featured == true || category == "news") && publishedAt < now()]
Without the brackets, the query would return every featured document of any type, plus published news posts, which is rarely what you meant.
Filtering on Missing and Empty Fields
| Goal | Filter |
|---|---|
| Field has a value | defined(mainImage) |
| Field is missing or null | !defined(mainImage) |
| Slug is set | defined(slug.current) |
| Array has at least one item | count(tags) > 0 |
| Array is missing or empty | !defined(tags) || count(tags) == 0 |
| String is not empty | defined(subtitle) && subtitle != "" |
defined(slug.current) is especially useful on listing pages. A post an editor created but never gave a slug would otherwise produce a broken link like /blog/null.
Filtering by Date
Sanity stores datetime fields as ISO 8601 strings in UTC, such as "2026-11-04T09:00:00Z". Because that format sorts lexicographically, plain string comparison works:
// Published posts only
*[_type == "post" && publishedAt <= now()]
// Scheduled posts
*[_type == "post" && publishedAt > now()]
// Posts from 2026
*[_type == "post" && publishedAt >= "2026-01-01" && publishedAt < "2027-01-01"]
For arithmetic, wrap the values in dateTime(). Subtracting two date-times gives seconds, and adding a number of seconds gives a new date-time:
// Posts from the last 30 days
*[_type == "post" && dateTime(publishedAt) > dateTime(now()) - 60*60*24*30]
now() returns the current time in UTC and is stable within a single query, so every comparison uses the same moment.
date fields (without a time) are stored as "2026-11-04". Comparing them with now() works for most purposes, but be aware that you're comparing a date with a full timestamp.
Filtering on Arrays
Arrays of strings, such as tags, use in with the value on the left:
// Posts tagged "groq"
*[_type == "post" && "groq" in tags]
// Posts that share any tag with a list
*[_type == "post" && count((tags[])[@ in ["groq", "sanity"]]) > 0]
array::intersects() makes the second case clearer:
*[_type == "post" && array::intersects(tags, ["groq", "sanity"])]
For arrays of objects, traverse the field with []:
// Products with any variant in stock
*[_type == "product" && count(variants[stock > 0]) > 0]
// Products with a red variant
*[_type == "product" && "red" in variants[].colour]
Nested arrays have their own patterns, covered in how to query nested arrays in GROQ.
Filtering by Reference
To find documents that point to a specific document, use references():
*[_type == "post" && references($categoryId)]
To filter on a field of the referenced document, dereference it inside the filter:
*[_type == "post" && author->slug.current == $authorSlug]
*[_type == "post" && $categorySlug in categories[]->slug.current]
Both work, but references() is usually faster because it uses an index of references, while -> in a filter has to resolve the reference for every candidate document. If you know the ID, prefer references(). The dereferencing guide explains joins in detail.
Sorting With order()
order() is a pipe function, so it comes after the filter with a |:
*[_type == "post"] | order(publishedAt desc)
The default direction is ascending, so order(title) and order(title asc) are the same.
Multiple Sort Keys
List several keys separated by commas. Later keys break ties in earlier ones:
// Pinned posts first, then newest
*[_type == "post"] | order(pinned desc, publishedAt desc)
// By category, then alphabetically within each category
*[_type == "post"] | order(category asc, title asc)
Adding _id as the last key gives a stable, fully deterministic order, which matters for pagination when several documents share a date.
Case-Insensitive Sorting
String sorting is case-sensitive, so "apple" can end up after "Zebra". Sort on a lowercased value instead:
*[_type == "author"] | order(lower(name) asc)
Sorting by a Computed Value
You can sort by any expression, including a field from a referenced document or a count:
// Authors with the most posts first
*[_type == "author"] {
name,
"postCount": count(*[_type == "post" && references(^._id)])
} | order(postCount desc)
Note where order() sits here: after the projection, so it can use the computed postCount. Sorting after a projection is fine for small result sets, but it means every document is projected before sorting, so keep it for queries that return a limited number of documents.
Where Missing Values Go
Documents with a null value for the sort key are grouped together rather than mixed in. If you don't want them at all, filter them out with defined() first:
*[_type == "event" && defined(startDate)] | order(startDate asc)
Putting It Together
Here's a realistic listing query for a blog category page, with parameters for the category and the page size:
*[
_type == "post"
&& defined(slug.current)
&& publishedAt <= now()
&& references($categoryId)
] | order(publishedAt desc, _id desc) [0...$limit] {
_id,
title,
"slug": slug.current,
publishedAt,
"author": author->name
}
And the matching call from a Next.js App Router page using next-sanity:
// src/app/category/[slug]/page.tsx
import { client } from '@/sanity/client'
import { defineQuery } from 'next-sanity'
const CATEGORY_QUERY = defineQuery(`*[_type == "category" && slug.current == $slug][0]{ _id, title }`)
const POSTS_QUERY = defineQuery(`*[
_type == "post"
&& defined(slug.current)
&& publishedAt <= now()
&& references($categoryId)
] | order(publishedAt desc, _id desc) [0...$limit] {
_id, title, "slug": slug.current, publishedAt, "author": author->name
}`)
export default async function CategoryPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const category = await client.fetch(CATEGORY_QUERY, { slug })
if (!category) return <p>Category not found.</p>
const posts = await client.fetch(POSTS_QUERY, { categoryId: category._id, limit: 12 })
return (
<section>
<h1>{category.title}</h1>
<ul>
{posts.map((post) => (
<li key={post._id}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
</section>
)
}
All values that change between requests are passed as GROQ parameters, so the query string stays the same and nothing from the URL is concatenated into it.
Performance Tips
- Filter on indexed basics first:
_type,_id,slug.currentandreferences()are cheap ways to narrow the set before more expensive conditions. - Avoid functions on fields in filters where possible:
lower(title) == "x"has to compute a value for every document. Store normalised values if you query them often. - Keep sorts on plain fields: sorting on a stored field like
publishedAtis cheaper than sorting on a computed value after a projection. - Slice after sorting:
order()then[0...10], never the reverse. - Measure in Vision: the Vision plugin shows how long each query takes, so you can compare two versions of a filter.
Common Mistakes
- Single quotes:
category == 'news'is invalid. GROQ strings use double quotes. - Slicing before ordering:
*[_type == "post"][0...5] | order(publishedAt desc)sorts five arbitrary posts. - Forgetting null behaviour:
!(rating > 3)doesn't return documents without a rating. - Missing brackets around
||: mixing&&and||without brackets changes the meaning. - Comparing slugs directly: slug fields are objects, so compare
slug.current, notslug.
FAQ: Filtering and Sorting With GROQ
Pipe the results into order() with the date field and a direction, for example *[_type == "post"] | order(publishedAt desc) for newest first. Datetime fields are stored as ISO strings in UTC, so they sort correctly as text.
Yes. Pass several keys separated by commas, such as order(pinned desc, publishedAt desc). The second key only affects documents that have the same value for the first key.
Common causes are using single quotes, comparing a string with a number, comparing slug instead of slug.current, or a field name typo. Comparing a missing field also returns null, which drops the document. Test each condition on its own in Vision to find the culprit.
Use !defined(field) for missing or null values. For arrays, combine !defined(field) with count(field) == 0 using an OR to catch both missing and empty arrays, because count() of a missing field returns null, and for strings, check for an empty string as well if editors can clear the field.
Generally yes. references() uses an index of which documents point to which, while using the arrow operator in a filter resolves each reference for every candidate document. Use references() when you have the target document's ID.
Yes, if you want the slice to reflect the sort. GROQ processes the query left to right, so order() then [0...10] returns the first ten sorted results, while slicing first sorts only the sliced items.
Conclusion
Filtering and sorting in GROQ come down to two habits: write precise boolean conditions inside square brackets, and pipe the results into order() before you slice. Start every filter with _type, use defined() to handle missing fields deliberately, prefer references() for relationships, and add a tiebreaker such as _id when the order must be stable.
With those patterns in place, you can build listing pages, archives and category views that return exactly the documents you expect. From here, learn to shape the results with projections, and when a list grows beyond a few pages, move on to pagination with GROQ.


