Validation
Describes how to validate documents in Sanity Studio.
Sanity Studio lets you specify validation rules on your document types and fields. Field-level validation is the most specific and gives the Studio a better chance to help the user understand where the validation failed and why, whereas the document-level validation provides slightly more control since it can validate based on the values of the entire document.
Each schema type has a set of built-in validation methods. See the schema type documentation for a detailed list →
Validation is client-side only
Schema validation rules only run in Sanity Studio. Mutations submitted through the API or client libraries are not checked against your validation rules. See Schema validation and the Content Lake for details.
You can also validate multiple documents in bulk using the CLI.
Basics
Validation is defined by setting the validation property on a document type or field. It takes a function which receives a rule as the first argument. By calling methods on this rule, you add new validation modifiers. Here's an example which validates that a string field has a value and that the string is between 10 and 80 characters long:
defineField({
title: 'Title',
name: 'title',
type: 'string',
validation: rule => rule.required().min(10).max(80)
})Without the required() call, the title is also considered valid if it does not have a value.
Error levels and error messages
By default, values that do not pass the validation rules are considered errors. These block the draft from being published until they have been resolved. You can also set a rule to be a warning by calling warning() on the rule. Similarly, you can customize the error message displayed by passing a string to the warning() or error() method:
defineField({
title: 'Title',
name: 'title',
type: 'string',
validation: rule => rule.max(50).warning('Shorter titles are usually better')
})If you want to combine both warnings and errors in the same validation set, you can use an array:
defineField({
title: 'Title',
name: 'title',
type: 'string',
validation: rule => [
rule.required().min(10).error('A title of min. 10 characters is required'),
rule.max(50).warning('Shorter titles are usually better')
]
})Referencing other fields
Sometimes you may want to build a rule that is based on the value of a different field. By calling the rule.valueOfField method, you can achieve this.
defineField({
title: 'Start date',
name: 'startDate',
type: 'datetime',
validation: rule => rule.required().min('2022-03-01T15:00:00.000Z')
}),
defineField({
title: 'End date',
name: 'endDate',
type: 'datetime',
validation: rule => rule.required().min(rule.valueOfField('startDate'))
})Note however that it only allows referencing sibling fields. If you need to refer to things outside of this scope, you will have to use document-level validation.
Gotcha
rule.valueOfField() returns the literal value of a field, allowing you to validate that the end date is always equal to or greater than the start date (as in the previous example). However, it cannot be used for inserting a field value into conditional logic and creating a validation based on the result.
Skip validation for hidden fields
The validation function receives a context parameter as the second argument. This context provides information about the field's current state. The context.hidden property indicates whether the field is hidden by a condition on itself or an ancestor.
Use rule.skip() to drop the validation rules on a field. This is useful for a conditionally hidden field whose required() rule would otherwise block publishing with an error the editor cannot see:
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (rule, context) => (context?.hidden ? rule.skip() : rule.required().min(5)),
})In this example, the validation function receives both the rule and context parameters. When context.hidden is true, rule.skip() returns a rule with no constraints, so the field passes validation. When the field is visible, required() and min(5) apply as usual.
The skip() method discards the validation rules set before it in the chain and makes the field optional. Rules chained after it still apply. That is what separates it from optional(), which adds a presence rule and leaves the earlier rules in place.
Custom validation
Sometimes you will need to validate values beyond what Sanity provides. The custom() method lets you do this. It takes a function as the first argument, which should return either true (in the case of a valid value) or an error message as a string (in the case of an invalid value). You may also return a promise that resolves with one of those values, should you need to do asynchronous operations:
defineField({
name: 'location',
type: 'geopoint',
title: 'Location of bar',
description: 'Required, must be in Norway',
validation: rule =>
rule.required().custom(geoPoint =>
someGeoService
.isWithinBounds(
{
latitude: geoPoint.lat,
longitude: geoPoint.lng
},
someGeoService.BOUNDS_NORWAY
)
.then(isWithinBounds => (isWithinBounds ? true : 'Location must be in Norway, somewhere'))
)
})Custom validators are also run on undefined values, unless the rule is explicitly set as optional by calling rule.optional(). This allows for conditionally allowing undefined values based on some external factor, with the slight drawback that you need to make sure your functions check for undefined values. Here's an example:
defineField({
name: 'breweryName',
type: 'string',
title: 'Brewery name',
validation: rule => rule.custom(name => {
if (typeof name === 'undefined') {
return true // Allow undefined values
}
// This would crash if we didn't check
// for undefined values first
return name.startsWith('Brew')
? 'Please be more creative'
: true
}).warning()
})Should you need to reference other fields from within the custom validator function, you can use the second argument (context) to the function:
defineField({
name: 'durationInMinutes',
type: 'number',
title: 'Duration of talk, in minutes',
validation: rule => rule.custom((duration, context) => {
const isShortTalk = duration && duration <= 10
if (isShortTalk && context.document.talkType !== 'lightning') {
return 'Only lightning talks should be 10 minutes or less'
}
return true
})
})You can also access the closest parent from the context, along with the path of the current element being validated.
Asynchronous validation using the client
If you want to base your rule on another part of your content, you can access the client via the validation context.
validation: (rule) =>
rule.custom((value, context) => {
const client = context.getClient({apiVersion: '2026-03-25'}).withConfig({perspective: 'drafts'})
// ...rest of your rule
return true
}),Validating children
In certain cases, you may want to validate children of an object or array. In this case you can return an array of error objects, each with a message and a path property. The path is an array of path segments leading to the child you want to flag as the culprit. Let's say you want to disallow empty blocks/paragraphs in a Portable Text field:
defineField({
name: 'introduction',
title: 'Introduction',
type: 'array',
of: [{type: 'block'}],
validation: rule => rule.custom(blocks => {
const emptyBlocks = (blocks || []).filter(
block =>
block._type === 'block' &&
block.children.every(span =>
span._type === 'span' &&
span.text.trim() === ''
)
)
const emptyErrors = emptyBlocks.map((block, index) =>
block._key
? {message: 'Paragraph cannot be empty', path: [{_key: block._key}]}
: {message: 'Paragraph cannot be empty', path: [index]}
)
return emptyErrors.length === 0 ? true : emptyErrors
})
})For each of the empty blocks we find, we collect the path to it, which can either be the _key property (preferably), or the array index if a key cannot be found.
Enforce a unique field value
Sanity has no built-in uniqueness constraint on ordinary fields. The slug type ships an isUnique check, but any other field needs the rule written by hand: query the dataset from a custom validator and return an error when the value is already taken. Validation runs in Sanity Studio only, so a uniqueness rule guards the editing experience rather than acting as a database constraint.
A uniqueness query has to ignore the document being edited, which can exist at the same time as a published document, a draft, and a version inside a content release. Pass the document's _id through getPublishedId() and exclude every version of it with sanity::versionOf(). Without that clause, a document always collides with itself.
import {defineField, defineType, getPublishedId} from 'sanity'
export const productType = defineType({
name: 'product',
type: 'document',
fields: [
defineField({
name: 'sku',
title: 'SKU',
type: 'string',
validation: (rule) =>
rule.required().custom(async (sku, context) => {
const {document, getClient} = context
if (!sku || !document) {
return true
}
const client = getClient({apiVersion: '2026-09-03'})
const isTaken = await client.fetch<boolean>(
`defined(*[
_type == "product" &&
sku == $sku &&
!sanity::versionOf($published)
][0]._id)`,
// getPublishedId strips the drafts. or versions. prefix, so
// sanity::versionOf excludes every version of this document
{sku, published: getPublishedId(document._id)},
)
return isTaken ? `The SKU ${sku} is already in use` : true
}),
}),
],
})Localized content needs one more decision before you write the query: must the value be unique within a single locale, or across every locale? A product code usually has to be unique everywhere, while a URL segment often only has to be unique within its own locale. The shape of the query then depends on which localization strategy the field uses.
Document-level localization
When each locale is a separate document carrying a language field, the value sits at the top level of a sibling document, so the check is an ordinary document filter. Keep the language == $language clause to enforce uniqueness within one locale, or remove it to enforce uniqueness across all of them.
import {defineField, defineType, getPublishedId} from 'sanity'
export const productType = defineType({
name: 'product',
type: 'document',
fields: [
defineField({
name: 'language',
type: 'string',
readOnly: true,
}),
defineField({
name: 'sku',
title: 'SKU',
type: 'string',
validation: (rule) =>
rule.required().custom(async (sku, context) => {
const {document, getClient} = context
if (!sku || !document) {
return true
}
const client = getClient({apiVersion: '2026-09-03'})
// Remove the language clause to enforce uniqueness across every locale
const isTaken = await client.fetch<boolean>(
`defined(*[
_type == "product" &&
language == $language &&
sku == $sku &&
!sanity::versionOf($published)
][0]._id)`,
{
sku,
language: document.language,
published: getPublishedId(document._id),
},
)
return isTaken ? `The SKU ${sku} is already used in this language` : true
}),
}),
],
})Field-level localization
When every locale lives in one document, the localized values sit inside an array, so a single field holds several values to check. Validate the array field, test each item, and return one error per colliding item so the message appears on the locale that caused it, following the same pattern as "Validating children". This example uses the internationalized array plugin, which stores the locale in a language field on each array item. It requires sanity-plugin-internationalized-array v5 or later, since v4 stored the locale in _key instead.
import {defineField, defineType, getPublishedId} from 'sanity'
type LocalizedString = {_key: string; _type: string; language: string; value: string}
export const productType = defineType({
name: 'product',
type: 'document',
fields: [
defineField({
name: 'sku',
title: 'SKU',
type: 'internationalizedArrayString',
validation: (rule) =>
rule.custom<LocalizedString[]>(async (items, context) => {
const {document, getClient} = context
const filled = (items ?? []).filter((item) => item.value)
if (filled.length === 0 || !document) {
return true
}
const client = getClient({apiVersion: '2026-09-03'})
const published = getPublishedId(document._id)
// Remove the language clause to enforce uniqueness across every locale
const query = `defined(*[
_type == "product" &&
count(sku[language == $language && value == $value]) > 0 &&
!sanity::versionOf($published)
][0]._id)`
const checked = await Promise.all(
filled.map(async (item) => ({
item,
isTaken: await client.fetch<boolean>(query, {
published,
language: item.language,
value: item.value,
}),
})),
)
// One error per colliding locale, so the message lands on that item
const errors = checked
.filter(({isTaken}) => isTaken)
.map(({item}) => ({
message: `The SKU ${item.value} is already used in ${item.language}`,
path: [{_key: item._key}, 'value'],
}))
return errors.length === 0 ? true : errors
}),
}),
],
})Each locale runs its own query, so a field with many locales sends many requests as the editor types. Custom validators have a non-configurable concurrency limit, covered in "Validation debouncing and delays", so use this pattern on fields with a short locale list.
Community plugins that add a unique string type enforce uniqueness globally on a single top-level field. That covers neither the per-locale nor the cross-locale case: they cannot scope a check to one locale, and they cannot reach a value nested inside a localized array. Custom validation is the approach that handles localized content.
Restrict publishing by user role
Conditional fields can hide or lock a field for users who lack a role, but they only change the form: a value already in the document still publishes. To block publishing, check the user's role in a custom validator. The validation context includes currentUser, which carries the roles assigned to the signed-in user. Sanity Studio passes it to validators from v5.14.0, and it is typed on ValidationContext from v6.20.0.
import {defineField, defineType} from 'sanity'
export const pageType = defineType({
name: 'page',
type: 'document',
fields: [
defineField({
name: 'title',
type: 'string',
validation: (rule) => rule.required().max(80),
}),
defineField({
name: 'marketingForm',
title: 'Marketing form',
type: 'object',
description: 'Only administrators can publish a page that contains a marketing form.',
// Lock the field in the form for everyone without the role
readOnly: ({currentUser}) => !currentUser?.roles.some(({name}) => name === 'administrator'),
fields: [
defineField({
name: 'formId',
title: 'Form ID',
type: 'string',
validation: (rule) => rule.required(),
}),
],
// Block publishing when the field has a value and the user lacks the role
validation: (rule) =>
rule.custom((value, context) => {
if (!value) {
return true
}
const isAdministrator = context.currentUser?.roles.some(
({name}) => name === 'administrator',
)
return isAdministrator
? true
: 'Only administrators can publish a page with a marketing form'
}),
}),
],
})currentUser.roles is an array, so match on name rather than comparing a single string, and use it in place of the deprecated role string, which the validation context does not include. currentUser can be null, so use optional chaining and let an absent user fail the check. CLI validation has no signed-in user, so sanity documents validate reports this error on every page that has a marketing form.
Document-level validation
Sometimes you want to validate a whole document rather than just specific fields in a document. To do this, you can give a document the validation property and access fields inside the document by passing a prop. In this example, the validation ensures that editors can't add a "Guest Author" and an "Author."
export default defineType({
name: 'post',
type: 'document',
title: 'Blog Post',
validation: rule => rule.custom(fields => {
if (fields?.authors?.length > 0 && Object.keys(fields?.guest ?? {}).length > 0) return "You can't have an author AND guest author"
return true
}),
fields: [
// ...
defineField({
name: 'authors',
title: 'Authors',
type: 'array',
of: [
{
type: 'authorReference',
}
]
}),
defineField({
name: 'guest',
title: 'Guest Author',
type: 'object',
fields: [
{name: 'name', type: 'string', title: 'Guest Author Name'},
{name: 'site', type: 'string', title: 'Guest Author Site'},
],
}),
]
})Marking nested fields as invalid
Similar to the example in "Validating children", you can return an object to specify what field the message should apply to when using document-level validation.
export default defineType({
name: 'post',
type: 'document',
title: 'Blog Post',
validation: (rule) =>
rule.custom((fields) => {
if (
fields?.authors?.length > 0 &&
Object.keys(fields?.guest ?? {}).length > 0
)
return {
message: "You can't have an author AND guest author",
path: ['guest'], // add keys to array for nested fields, ex ['guest', 'title'] for guest.title
}
return true
}),
fields: [
// ...
defineField({
name: 'authors',
title: 'Authors',
type: 'array',
of: [
{
type: 'authorReference',
},
],
}),
defineField({
name: 'guest',
title: 'Guest Author',
type: 'object',
fields: [
defineField({ name: 'name', type: 'string', title: 'Guest Author Name' }),
defineField({ name: 'site', type: 'string', title: 'Guest Author Site' }),
],
}),
],
})Validation cascade
When referencing schema types from other types, such as when setting a document's fields to custom types, any "higher level" validation rules will override existing validation rules.
For example, if you have an articleType and authorType as shown in the example below, any rules applied to the field in articleType will override the authorType rules.
export const authorType = defineType({
type: 'object',
name: 'authorType',
validation: (rule) => rule.custom(...)
})
export const articleType = defineType({
type: 'document',
name: 'articleType',
fields: [
defineField({
type: 'authorType',
validation: (rule) => rule.custom(...) // Overrides the authorType validation
})
]
})Validation debouncing and delays
Validations don’t include any debouncing. They run in parallel on document updates. Custom validations do have a non-configurable concurrency limit, so we recommend keeping your validations performant.
You can incorporate throttling into custom validations, but be careful not to negatively impact the user experience. If you find that you need intentionally slow / long-running custom validations, you can prevent the console warning by setting the bypassConcurrencyLimit to true.
Validation messages in arrays
Sanity Studio surfaces validation messages differently for arrays of primitives and arrays of objects. The shared item-render API passes a validation prop to the item renderer in both cases; the visible difference is what the default item component does with it.
- Arrays of primitives: each item renders inline with its input. Validation messages appear next to the input in the standard form layout.
- Arrays of objects: each item renders as a collapsible preview row. The row shows a tone indicator (error or warning) for compactness, and the full message text appears at the array-field level. Authors see the message text inline by opening the item.
This is a UX choice that keeps the preview row scannable. If you want each row of an object array to display the full message text, override the item component with the components.item slot.
Show per-item validation messages
Provide a custom item component that reads props.validation and renders the messages. Use props.renderDefault(props) to keep the default preview row and decorate around it, rather than replacing the rendering entirely.
import {defineField} from 'sanity'
import type {ItemProps} from 'sanity'
export function ItemWithValidation(props: ItemProps) {
const errors = props.validation.filter((marker) => marker.level === 'error')
return (
<div>
{props.renderDefault(props)}
{errors.length > 0 && (
<ul style={{margin: 0, padding: '4px 12px', color: 'red'}}>
{errors.map((marker, i) => (
<li key={i}>{marker.message}</li>
))}
</ul>
)}
</div>
)
}
export const tagsField = defineField({
name: 'tags',
type: 'array',
of: [{type: 'tag'}],
components: {item: ItemWithValidation},
})Schema-level or workspace-level scope
The example above applies the override to a single field. You can also apply it across the workspace by setting form.components.item on your Studio configuration:
import {defineConfig} from 'sanity'
import {ItemWithValidation} from './tagsField'
export default defineConfig({
// ...
form: {
components: {
item: ItemWithValidation,
},
},
})Use the schema-level slot when one array needs a different treatment from the rest of the Studio. Use the workspace-level slot to apply the same rendering to every array in the workspace. The component receives the same ItemProps in both cases, so the same custom component works at either scope.
Related resources
- Form Components covers the full set of override slots, including
components.input,components.field, andcomponents.preview. - Configuration covers workspace-level
form.componentssetup in context.
Was this page helpful?
Feedback submitted, thanks for helping us improve the docs. Have you tried these alternatives?