
Writing Content Migration Scripts in Sanity
A Sanity content migration is a TypeScript or JavaScript file in your Studio's migrations folder that exports defineMigration() from sanity/migrate. It declares which documents to touch, using documentTypes and an optional GROQ filter, and a migrate object whose handlers return patches built with helpers like at(), set(), setIfMissing() and unset(). You scaffold one with npx sanity migrations create, preview the patches with npx sanity migrations run <id>, which is always a dry run, and apply them with --no-dry-run. The tooling batches the patches into transactions and respects API rate limits for you.
Changing a schema in Sanity never changes the content already stored. Rename a field in your schema and every existing document still has the old field name, which your Studio now flags as unknown and your queries no longer find. Migrations are how you bring existing content in line with a new content model, in a way that's reviewable, repeatable and committed to Git. This guide covers when you need one, the anatomy of a migration file, the helper functions, worked examples for common changes, how to run them safely and how to make them idempotent.
When You Need a Migration
You need a migration whenever existing documents must change shape:
- Renaming a field, such as
locationtoaddress. - Changing a field's type, such as a single reference to an array of references.
- Restructuring content, such as moving loose fields into an object, or a plain string into Portable Text.
- Backfilling values for a new required field.
- Cleaning up documents of a retired type or values left by an import.
You don't need one for purely additive changes. A new optional field or document type affects nothing that already exists. And you can't use one to change _id or _type, which are immutable; more on that later.
Migrations change content within a dataset. To copy content between datasets or projects, see how to migrate content between Sanity datasets. To move from another CMS, see migrating from WordPress to Sanity.
Find the Documents That Need Changing
Before writing code, see how big the problem is. sanity documents validate runs your current schema's validation rules against every document in a dataset and reports fields that don't exist in the schema:
npx sanity documents validate --dataset staging -y --format ndjson > validation.ndjson
A rename shows up as warnings like this:
{"documentId":"person_robin","documentType":"person","markers":[{"path":["fullName"],"level":"warning","message":"Field 'fullName' does not exist on type 'person'"}],"level":"warning"}
A quick GROQ count in Vision gives the same answer for a single field:
count(*[_type == "person" && defined(fullName)])
Scaffold a Migration
Run the generator from your Studio folder, the one with sanity.cli.ts:
npx sanity migrations create "Rename fullName to name"
The CLI asks which document types to target and offers a few templates, then writes a file such as migrations/rename-fullname-to-name/index.ts. Migrations can be single files (migrations/my-migration.ts) or folders with an index.ts, which is useful when a complex migration needs helper modules. The folder or file name becomes the migration ID.
Run npx sanity migrations list to see every migration the CLI can find, with its ID and title. You'll pass that ID to migrations run.
Anatomy of a Migration File
// migrations/rename-fullname-to-name/index.ts
import { defineMigration, at, setIfMissing, unset } from 'sanity/migrate'
export default defineMigration({
title: 'Rename fullName to name',
documentTypes: ['person'],
filter: 'defined(fullName)',
migrate: {
document(doc) {
return [
at('name', setIfMissing(doc.fullName)),
at('fullName', unset()),
]
},
},
})
The four properties:
| Property | Purpose |
|---|---|
title | A readable description, shown in migrations list and the run output |
documentTypes | Which _type values to process. Omit it and every type is processed |
filter | An extra GROQ filter evaluated per document. It can't use joins or query other documents |
migrate | Handlers that receive content and return the changes to make |
Narrowing with documentTypes and filter matters for speed and safety. A migration with neither visits every document in the dataset.
The Handlers
migrate can include any of these functions. Each is called for every matching value, and you return nothing to leave it unchanged:
| Handler | Called for | Arguments |
|---|---|---|
document | Each matching document | (doc, context) |
node | Every value anywhere in the document | (node, path, context) |
object | Every object | (node, path, context) |
array | Every array | (node, path, context) |
string | Every string | (node, path, context) |
number | Every number | (node, path, context) |
boolean | Every boolean | (node, path, context) |
null | Every null value | (node, path, context) |
The path tells you where a value sits. For a slug it's ['slug', 'current']. For text inside Portable Text it includes array keys, such as ['body', { _key: '77b1...' }, 'children', { _key: 'o9Bc...' }, 'text']. When a value-level handler like string returns set(...), the change applies at that path automatically. In document, you use at() to say where each change goes.
The context argument includes a read-only client, so a handler can query other documents when it needs to.
The Helper Functions
All helpers are imported from sanity/migrate:
| Helper | What it does |
|---|---|
at(path, op) | Applies an operation at a path, such as at('seo.title', set('Hi')) |
set(value) | Sets a value, overwriting what's there |
setIfMissing(value) | Sets a value only if the path is empty |
unset() | Removes the value at the path |
inc(n) / dec(n) | Increments or decrements a number |
append(items) / prepend(items) | Adds items to the end or start of an array |
insert(items, 'before' or 'after', ref) | Inserts items next to an index or { _key }. insertBefore and insertAfter are shortcuts |
replace(items, ref) | Replaces an array item, by index or { _key } |
truncate(start, end?) | Removes array items from start, optionally up to end |
patch(id, ops) | Builds a patch for another document ID |
create, createIfNotExists, createOrReplace | Create documents |
delete_(id) | Deletes a document (also exported as del) |
Under the hood, these become the same patches and mutations you'd send through the Mutation API. The runner also enables autoGenerateArrayKeys, so objects you add to arrays get a _key without extra work.
Worked Examples
Convert a Reference Into an Array of References
A product used to have one category. Now it can have several:
// migrations/category-to-categories/index.ts
import { defineMigration, at, setIfMissing, append, unset } from 'sanity/migrate'
export default defineMigration({
title: 'Convert category reference into categories array',
documentTypes: ['product'],
filter: 'defined(category) && !defined(categories)',
migrate: {
document(product) {
return [
at('categories', setIfMissing([])),
at('categories', append(product.category)),
at('category', unset()),
]
},
},
})
The filter makes it safe to run twice: once a document has categories, it's skipped. Remember to update the schema too, using defineArrayMember for the new reference array, as shown in Sanity arrays.
Turn a Plain String Into Portable Text
An excerpt was a text field and is now block content:
// migrations/excerpt-to-portable-text/index.ts
import { pathsAreEqual, stringToPath } from 'sanity'
import { defineMigration, set } from 'sanity/migrate'
const targetPath = stringToPath('excerpt')
export default defineMigration({
title: 'Convert excerpt string to Portable Text',
documentTypes: ['post'],
migrate: {
string(node, path) {
if (!pathsAreEqual(path, targetPath)) return
return set([
{
_type: 'block',
style: 'normal',
markDefs: [],
children: [{ _type: 'span', text: node, marks: [] }],
},
])
},
},
})
Because the string handler only sees strings, a document that has already been converted is ignored on a second run. For converting HTML rather than plain text, see HTML to Portable Text.
Replace a Value Everywhere It Appears
Value-level handlers shine when the data could be anywhere, including deep inside Portable Text:
// migrations/rename-brand/index.ts
import { defineMigration, set } from 'sanity/migrate'
export default defineMigration({
title: 'Replace "Acme Ltd" with "Acme Group"',
documentTypes: ['page', 'post'],
migrate: {
string(node) {
if (node.includes('Acme Ltd')) {
return set(node.replaceAll('Acme Ltd', 'Acme Group'))
}
},
},
})
Backfill Values for Every Document
For large datasets, the migrate property can be an async generator. It streams documents one at a time instead of loading them all into memory:
// migrations/backfill-featured/index.ts
import { defineMigration, patch, at, setIfMissing } from 'sanity/migrate'
export default defineMigration({
title: 'Set featured to false where missing',
documentTypes: ['post'],
async *migrate(documents) {
for await (const doc of documents()) {
yield patch(doc._id, [at('featured', setIfMissing(false))])
}
},
})
Look Up Other Documents
The filter can't query other documents, but context.client can. This example copies each post's author name into a denormalised field:
// migrations/denormalise-author-name/index.ts
import { defineMigration, at, set } from 'sanity/migrate'
export default defineMigration({
title: 'Copy author name onto posts',
documentTypes: ['post'],
filter: 'defined(author._ref)',
migrate: {
async document(doc, context) {
const ref = (doc.author as { _ref: string })._ref
const name = await context.client.fetch<string | null>(
`*[_id == $id][0].name`,
{ id: ref },
)
if (!name) return
return at('authorName', set(name))
},
},
})
Each lookup is an API request, so use this sparingly on large datasets.
Running a Migration
Dry Run First
npx sanity migrations run rename-fullname-to-name
Without --no-dry-run, nothing is written. The CLI prints every patch it would submit, with document IDs, so you can check the shape of the changes. You can also dry run against a local export, which costs no API requests:
npx sanity migrations run rename-fullname-to-name --from-export ./exports/production.tar.gz
Apply It
# Against the dataset in sanity.cli.ts
npx sanity migrations run rename-fullname-to-name --no-dry-run
# Against a specific dataset (requires --project as well)
npx sanity migrations run rename-fullname-to-name --project abc123xy --dataset staging --no-dry-run
The CLI asks for confirmation, then shows how many documents were processed, mutations generated and transactions committed. Useful flags:
--concurrency: how many transactions run in parallel, from 1 to 10. The default is 6. Lower it if you hit429rate-limit errors.--no-confirm: skip the prompt, for CI.--api-version: the API version used for the run.
A Safe Workflow for Production
- Export production with
npx sanity datasets export production backup.tar.gz. - Copy it to staging so you test against real content.
- Change the schema in a branch. Mark old fields as
deprecatedwith areasonandreadOnly: trueso editors know what's happening. - Write the migration and dry run it against staging.
- Apply to staging, then test the Studio and front end.
- Make front-end queries tolerant of both shapes, for example
"name": coalesce(name, fullName), so the site works before and after the migration. - Deploy, then migrate production with
--no-dry-run, and warn editors first, since they may be editing while it runs. - Remove the fallbacks once everything is confirmed.
How datasets map to environments is covered in Sanity datasets and environments.
Make Migrations Idempotent
An idempotent migration can run any number of times with the same result. That matters because runs get interrupted, and someone will run it twice eventually.
- Safe:
at('name', set(doc.name.toUpperCase()))always produces the same value. - Not safe:
at('members', append({ name: 'Some One' }))adds another member every run.
The easiest fix is a precondition in filter, such as defined(category) && !defined(categories). Where that isn't possible, record that a document has been migrated:
import { defineMigration, at, setIfMissing, append } from 'sanity/migrate'
const MIGRATION_KEY = 'add-default-member-2026-11'
export default defineMigration({
title: 'Add default member to teams',
documentTypes: ['team'],
migrate: {
document(doc) {
const done = (doc._migrations as string[] | undefined) ?? []
if (done.includes(MIGRATION_KEY)) return
return [
at('members', setIfMissing([])),
at('members', append({ _type: 'member', name: 'Some One' })),
at('_migrations', setIfMissing([])),
at('_migrations', append(MIGRATION_KEY)),
]
},
},
})
Things Migrations Can't Do
- Change
_idor_type: these are immutable. To change a type, export the dataset, edit the NDJSON, delete the old documents and import the edited ones. - Bypass references: deleting a document that other documents strongly reference fails. Remove or weaken the references first.
- Validate for you: the Content Lake doesn't enforce your schema, so a buggy migration writes buggy data. That's why the dry run matters.
Remember too that drafts are separate documents with IDs like drafts.person_robin and the same _type, so check the dry-run output to see which versions of a document your migration touches.
FAQ: Sanity Content Migrations
No. Schema changes only affect the Studio's forms and validation. Existing documents keep their old structure until you change them, which is what content migrations are for.
In a migrations folder at the root of your Studio project, next to sanity.cli.ts. Each migration is a .ts or .js file, or a folder with an index file, and its name becomes the migration ID.
No. It always performs a dry run and prints the patches unless you add the --no-dry-run flag. Review that output, and back up the dataset, before running it for real.
Yes. Pass --dataset with the dataset name, together with --project and your project ID. Without these flags, the migration uses the project and dataset in sanity.cli.ts.
Lower the --concurrency flag, which controls how many transactions run in parallel. It accepts values from 1 to 10 and defaults to 6. Narrowing documentTypes and filter also reduces the number of patches.
No. The _id and _type fields are immutable. To change a document type, export the dataset, edit the NDJSON file, delete the original documents and import the changed ones.
Conclusion
Content migrations let you evolve a Sanity content model without hand-editing hundreds of documents. A defineMigration file narrows its target with documentTypes and filter, describes changes with at(), set(), unset() and the other helpers, and runs through the CLI with a dry run by default.
Treat migrations like database migrations: commit them, test them on a copy of production, make them idempotent, and keep your front end tolerant of both shapes while the change rolls out. Do that, and renaming a field or restructuring content becomes a routine task rather than a risky one.


