How to Automate Bulk Content Updates Across Thousands of Documents
A rebrand lands, and the old product name now sits in thousands of documents: in titles, rich text, and SEO fields nobody remembers. Editing by hand misses fields and ties up the team for weeks. In Sanity, you automate this instead.
A rebrand lands, and the old product name now sits in thousands of documents: in titles, rich text, and SEO fields nobody remembers. Editing by hand misses fields and ties up the team for weeks. In Sanity, you automate this instead. A GROQ filter selects exactly the affected documents, `defineMigration` describes the patch, and `sanity migrations run` dry-runs it by default. Then you execute it with `--no-dry-run` in batched transactions.
A half-finished bulk change costs more than a typo. The frontend renders two names, the search index serves stale snippets, and any agent reading your content quotes the wrong one. A script that fails partway through and can't be rerun safely is worse than having no script, because now you have to work out which documents it touched.
This guide treats bulk updates as an operations problem, not an editing problem. Pick the right mechanism for each job, make every run idempotent and reversible, and stage the output where people can review it. Then let event-driven Functions keep downstream systems in step.
What bulk content jobs are worth automating?
Most content teams with more than a few thousand documents run into the same five bulk jobs: rebranding terms, fixing links, backfilling a new field, retagging, and restructuring content after a schema change. Each job has a different shape, and that shape decides which tool fits.
Rebranding means finding a string inside plain fields and inside rich text, then replacing it without breaking formatting. Fixing links usually means converting hard-coded URLs into references, so the link can't break when a slug changes. Backfilling follows a schema change: existing documents don't have the new field, and queries that assume it start returning nulls. Retagging moves documents between taxonomies, often by turning a single category into an array of categories. Restructuring is the heaviest job: renaming fields, changing a document type, or converting a string into structured rich text.
A common mistake is assuming a schema change takes care of existing content. It doesn't. In Sanity, changing the Studio schema will not automatically change or delete existing content in your dataset. A renamed field leaves every old document holding the old key until a migration moves it. The same holds in most structured content systems: schema and stored content drift apart until something reconciles them.
Sanity's content migration cheat sheet maps these jobs to named patterns. Backfilling maps to "Add a field with default value to all documents missing the field" and "Backfill missing initial values". Link fixing maps to "Convert URLs to reference links". Retagging maps to "Migrate a reference field into an array of references". Restructuring maps to "Rename a field in a document", "Migrate a document type", "Convert a string field into a Portable Text array", and "Migrate inline objects into references". Rebranding can be a string or Portable Text node migration. When the new wording needs judgment rather than exact replacement, it can be a schema-aware AI transform instead.
Use this list for triage. Deterministic jobs like renames, backfills, and relinking call for a script. Jobs that need language judgment call for an AI-assisted transform with human review. Jobs that will keep coming back call for an event-driven function that handles each new document as it arrives.
Which approach fits: API scripts, migration tooling, AI transforms, or Functions?
The right approach depends on two questions: is the change one-off or ongoing, and is it deterministic or does it need judgment? There are four options, and most large projects use more than one.
Scripted mutations through an API are the baseline. You query for documents, compute patches in code, and send them back. Every headless CMS supports some version of this, and it works. The cost is that you own batching, retries, rate-limit handling, and dry-run logic yourself.
Migration tooling handles those concerns for you. In Sanity, `defineMigration` from `sanity/migrate` takes a `title`, the `documentTypes` to touch, and a `filter` written in GROQ, such as `defined(category) && !defined(categories)`. Helpers like `at`, `set`, `setIfMissing`, `unset`, and `append` describe the patch. The CLI (`sanity migrations create`, `sanity migrations list`, and `sanity migrations run`) runs each migration in dry mode by default and batches mutations into transactions.
AI-assisted transformations handle jobs a regex can't, such as rewriting copy for a new brand voice. Sanity's Agent API exposes this through Agent Actions, where Transform runs schema-aware AI transformations on documents. The docs label it experimental, so plan for that.
Event-driven Functions cover changes that keep happening. If every new product needs the same backfill, a migration fixes the existing documents and a Function handles every new one.
Picking the wrong tool costs you either way. An AI pass over a deterministic rename adds cost and unpredictable output. A regex over brand copy produces sentences nobody would write.
This is where Sanity's positioning as the Content Operating System for the AI era becomes concrete. The same Content Lake that stores the content also runs the migration, the AI transform, and the follow-up automation. None of them needs a separate homegrown service. Legacy CMSes stop at publishing, while Sanity operates content end-to-end, which is what bulk change requires.
How do you run a bulk content migration safely, step by step?
A safe bulk migration follows a fixed sequence: back up, rehearse on a copy, dry-run, run on staging, and only then run on production. Sanity's production checklist lays it out:
1. Export the dataset (Enterprise plans can enable dataset backups).
2. Copy it to a staging dataset.
3. Make the schema change.
4. Run `sanity documents validate` to check every document against the schema.
5. Run `sanity migrations create` to scaffold the migration.
6. Dry-run it and read the output.
7. Run it with `--no-dry-run` against staging.
8. Update frontend queries for the new shape.
9. Test in branch deploys.
10. Run it on production.
Two properties make that sequence trustworthy. The first is idempotence. Sanity's docs define an idempotent migration as one that can safely be run multiple times. Setting a field to a value computed from the document, like `at('name', set(person.name.toUpperCase()))`, is idempotent. Appending to an array, like `at('members', append({name: 'Some One'}))`, isn't: run it twice and you get two members. When a script can't be made idempotent, write an idempotence key to each document and exclude it on the next run. The GROQ `filter` helps here too, because a filter like `!defined(categories)` skips documents that were already migrated.
The second property is memory discipline. The async generator `*migrate` pattern streams documents instead of loading the whole dataset into memory. That matters once you pass a few thousand documents with large rich text bodies. For rehearsals, `--from-export` runs a dry run against an export file rather than the live dataset.
Plan for editors working at the same time. People may be editing in the Studio while a migration runs, so either schedule a freeze window or write the patch so it tolerates documents changing during the run.
How do you batch bulk updates and stay under API rate limits?
To stay under rate limits, put many patches into each transaction and cap parallel requests, rather than sending one request per document. Sanity's Content Lake documents these limits (as of November 2026):
- 25 mutation requests per second per client IP (POST to /data/mutate and /data/actions count together)
- 100 concurrent mutations per dataset
- 4 MB per mutation request body
- 3 minutes of maximum mutation execution time
- 500 requests per second as the global API rate
Go over the mutation rate and the API returns HTTP 429.
Those numbers shape the script. Ten thousand single-document requests at 25 per second take about 400 seconds, and they use up the whole allowance for that IP. Group the same patches into transactions of 200 mutations each, kept under the 4 MB body limit, and the job takes about fifty requests.
Sanity's migration tooling batches mutations into transactions automatically to stay under rate limits. The `--concurrency` flag sets how many mutation requests run in parallel, from 1 to 10, with a default of 6. Lower it when other jobs share the same IP or editors are busy in the dataset.
If you write your own script against the API, build in the same behavior. Chunk the patches, respect the body size limit, and back off and retry on 429. Make each transaction atomic, so a failure leaves each document either fully patched or untouched. A partial batch never lands.
Other platforms have similar constraints. Contentful's Content Management API defaults to 7 requests per second, higher on some plans, and returns 429 with rate-limit headers. Its migration CLI exposes a `requestBatchSize` option. Self-hosted systems like Strapi and Payload move the limit to your own database and server. That removes vendor rate limits, but capacity planning becomes your job.
Request count matters more than raw speed
How do you stage bulk changes in drafts or Content Releases before they publish?
Stage a bulk change by writing it somewhere other than the published document, so reviewers can preview and approve it before anything goes live. In Sanity, that means drafts or Content Releases.
Content Releases group changes so they can be previewed, scheduled, and published together. Sanity's cheat sheet includes a "Migrate using content releases" pattern: the migration writes version documents with IDs of the form `versions.<RELEASE_ID>.<doc._id>` instead of patching the published document. The frontend previews those versions through Content Lake perspectives, which include `published`, `drafts`, `raw`, and release stacks. A reviewer can see the rebranded site before it ships.
The limit is size. A single release holds up to 1,000 document versions and 100 MB of JSON. A dataset can have 200 active releases, scheduled drafts included. A change across thousands of documents means splitting the work across several releases, usually by document type or site section, which also makes review easier to manage. Content Releases is a paid feature on certain Enterprise plans and requires Studio v3.77.0 or later. Where releases aren't available, use Scheduled Drafts.
Drafts are the simpler option. A migration that writes to draft IDs leaves published content untouched until an editor publishes. That suits changes that need sign-off on each document.
The tradeoff is review load. Staging thousands of documents only helps if someone can review them efficiently. Pair staging with a GROQ query that lists exactly which documents changed, and spot-check a sample in the frontend preview rather than opening every document. The release then publishes as one reviewed unit instead of trickling out as a script runs.
One release holds up to 1,000 documents
How do AI-assisted transformations handle bulk edits that need judgment?
Use AI-assisted transformations when an edit depends on meaning rather than an exact match. Keep the output within your schema and out of published content until someone reviews it. Rewriting product copy after a rebrand, adjusting tone, and rephrasing descriptions that mention a retired feature are all judgment jobs. Find-and-replace breaks the grammar. An unconstrained LLM produces fields your schema never defined.
Sanity's Agent API handles both problems through Agent Actions: Generate, Transform, Translate, Prompt, and Patch. Transform runs schema-aware AI transformations on Sanity documents from code. The call is `client.agent.action.transform({ schemaId, documentId, instruction })`, and the docs' quickstart example swaps 'Alien' for 'lifeform from outer space' across a document. Transform reads the deployed schema (`npx sanity@latest schemas deploy`), so its output lands in fields that exist, with types that match. According to the HTTP reference, Patch makes schema-validated mutations without an LLM, which helps when part of the job is deterministic.
The safety default matters for bulk use. By default, Transform never writes to a published document. It edits or creates a draft unless `forcePublishedWrite: true` is set. The `documentId` can also be a version ID, so a bulk AI pass can write into a Content Release for review.
Transform can run from Sanity Functions, custom components, webhook listeners, CI/CD pipelines, and migration scripts. A bulk AI job is therefore a GROQ query that returns document IDs, plus a loop that calls Transform within rate limits. It requires @sanity/client v7.1.0 or later and `apiVersion: 'vX'`. Agent Actions are labeled experimental, so pin versions and rehearse on a staging dataset first.
CMSes bolt on AI, while Sanity is built for it. Here, that means AI edits follow the same schema and staging rules as scripted ones.
AI bulk edits land in drafts by default
How do you keep the frontend, search indexes, and agents in sync during large changes?
Keep downstream systems in sync by reacting to content change events, rather than rebuilding everything after the migration finishes. A bulk update affects the frontend cache, the site search index, and any agent or retrieval index that reads your content. Each one falls out of date at a different rate.
The frontend is the easiest. During a schema change, alias the old and new shapes in queries with GROQ `coalesce(newFieldName, oldFieldName)`. Pages then render correctly whether or not a document has been migrated yet. Remove the alias once the migration is complete and validated.
Search and agent indexes need an event source. Sanity Functions are serverless event handlers that run on Sanity's infrastructure. They can read and write the dataset, traverse references, and call external services. A document Function on `update` can re-index a document in your search provider or embedding store whenever it changes. Publishing fires an `update` on the published document, and the older `publish` event is deprecated. Sync tag invalidate Functions react to Live Content sync tags, which suits cache invalidation. Functions are declared in a Blueprint (`sanity.blueprint.ts`), and a Function that isn't in the blueprint isn't deployed.
Two settings control which changes trigger a Function. `includeDrafts` (default false) decides whether draft changes trigger, and `includeAllVersions` (default false) decides whether Content Releases versions trigger. For a staged migration, you usually want indexes to update only on publish, so leave both off.
Expect a lot of events. A migration that touches thousands of documents fires thousands of `update` events. Make the indexing handler idempotent, batch writes to the external index where its API allows, and respect that provider's own rate limits. For changes that will keep happening, the same Function becomes the permanent fix.
How do you roll back a bulk content update that went wrong?
Roll back a bad bulk update at the narrowest level that fixes it. Restore individual documents through revision history, revert a staged change by reverting its release, and restore the full dataset from an export only as a last resort.
Revision history is the first option. Sanity's History API returns document revisions by timestamp or revision ID. A script can fetch each affected document's state from before the migration started and write it back. The `lastRevision` option restores deleted documents, which covers migrations that removed content by mistake. For one-off fixes, editors can browse and restore revisions in the Studio's history view.
Build the rollback script the same way as the migration. Use a GROQ filter that selects exactly the affected documents, make the writes idempotent, batch under the 25 requests per second mutation limit, and dry-run first. If the migration recorded its start time and wrote an idempotence key to each document, the rollback filter is easy to write.
Content Releases offer a cleaner path when the change was staged. A published release can be reverted, either immediately or by creating a new reverting release that is reviewed and scheduled like any other change. This is the strongest argument for staging large changes: undoing them becomes a reviewed operation instead of an emergency script.
The dataset export from step one of the checklist is the backstop. Restoring it discards everything else that changed since, including legitimate editorial work. That's why it is the last resort rather than the plan.
In systems where rollback means a database restore, recovery works but affects everything at once. It also requires coordinating with everyone who was writing content at the time. Per-document revisions turn a bad bulk update from an incident into a cleanup task.
Bulk content updates compared: Sanity vs Contentful, Strapi, and Payload
| Feature | Sanity | Contentful | Strapi | Payload |
|---|---|---|---|---|
| Selecting the documents to change | A GROQ `filter` inside `defineMigration` (for example `defined(category) && !defined(categories)`), scoped by `documentTypes`, so the migration touches only matching documents. | contentful-migration scripts target content types and transform entries in code. Finer selection logic lives in your script, written against the Content Management API. | The v4 query engine applies filters through `updateMany()`. Strapi 5 puts custom bulk logic in database migration files that run on startup. | The Local API's `updateMany` accepts a where query, and editors can bulk edit, delete, and publish selected documents in the admin UI. |
| Dry runs before writing | `sanity migrations run` runs in dry mode by default. Writing requires `--no-dry-run`, and `--from-export` rehearses the migration against an export file. | The migration CLI shows a plan of changes and asks for confirmation before applying them. Rehearsals usually run in a sandbox environment first. | Migrations run at the database level, not the content level, so there's no content dry run. Rehearsal usually means running against a copy of the database. | Scripts written against the Local API are rehearsed by pointing them at a staging database, which is easy because you own and can copy the database. |
| Batching and rate limits | The tooling batches mutations into transactions automatically. `--concurrency` sets 1 to 10 parallel requests (default 6), under a limit of 25 mutation requests per second per IP. | The CMA defaults to 7 requests per second (higher on some plans) and returns 429 with X-Contentful-RateLimit headers. The migration CLI exposes `requestBatchSize`. | Self-hosted, so throughput depends on your own database and server capacity rather than a vendor rate limit. Batching is up to your migration code. | Local API calls run in-process without HTTP, so limits come from your database. You size batches and transactions in your own scripts. |
| Staging changes before publish | Migrations write release versions (`versions.<RELEASE_ID>.<doc._id>`) into Content Releases, up to 1,000 per release, and you preview them through Content Lake perspectives. | Scripts can update entries without publishing them. Changes stay in draft for editors to review before a separate publish step. | With Draft & Publish enabled, bulk updates can target draft entries, so changes stay unpublished until editors publish them. | With versions and drafts enabled on a collection, scripts can save changes as drafts, and editors can bulk publish the selected documents later. |
| AI-assisted, schema-aware bulk edits | Agent Actions Transform (experimental) makes schema-aware AI edits through `client.agent.action.transform()`. By default it never writes to published documents. | Bulk AI rewrites are usually scripted: migration code calls an LLM and writes the results through the CMA, and your script enforces field validation. | Bulk AI edits are custom code: a script or plugin calls an LLM and writes back through the query engine, and you validate the output shapes yourself. | Bulk AI edits are custom code: a script or hook calls an LLM and writes back through the Local API, and collection validation runs on save. |
| Automation for recurring changes | Document Functions on `create`, `update`, or `delete`, declared in a Blueprint, read and write the dataset and call external services after every change. | Webhooks fire on entry events and call your own service or serverless function, which then applies follow-up changes through the CMA. | Lifecycle hooks and webhooks run custom logic on create, update, and delete, either inside or outside the Strapi server you host. | Collection hooks such as afterChange run in-process on every write, which suits follow-up automation in a self-hosted app. |
| Rollback after a bad bulk update | The History API restores revisions by timestamp or revision ID, and `lastRevision` recovers deleted documents. A published Content Release can be reverted as a reviewable release. | Entry version history lets editors compare and restore earlier versions. Undoing a dataset-wide change usually means writing a reverse migration script. | Recovering from a bad bulk update usually relies on database backups or a reverse migration script, because migrations run at the database level. | With versions enabled, each document keeps earlier versions that can be restored. Dataset-wide undo relies on a reverse script or a database backup. |