The Pre-Migration Checklist for Sanity

Most migration horror stories don’t start with a bad migration script — they start with a migration that ran before anyone understood the data it was touching. The script worked exactly as written. The problem was that nobody had counted the documents, cloned the dataset, or written down how to undo it.

A Sanity migration is easy to write and hard to reverse. The five minutes you spend on the checklist below is what turns the migration itself into the boring, uneventful step it should be. This guide is platform-agnostic — work through it whether you’re coming from WordPress, Contentful, Drupal, a folder of Markdown, or reshaping a schema you already run in Sanity.

Step 1: Audit Your Source Content

You can’t migrate what you haven’t measured. Before any code, build a true inventory of what’s actually there — not what the admin UI suggests is there.

Catalog the shape:

  • How many content types exist, including custom post types, components, and one-off “page” variants?
  • What fields does each type carry — and which are actually populated versus defined-but-empty?
  • What taxonomies and relationships connect documents (categories, tags, authors, cross-references)?
  • Where do assets live — a media library, hotlinked external URLs, inline in rich text, or all three?
  • What’s the real volume? Document counts, asset counts, and the largest single documents.

Tip

Measure volumes from the database or API, not the admin dashboard. The UI hides drafts, orphaned revisions, and unpublished content that your migration will still have to account for. A count that’s off by 30% is usually the UI not showing you everything.

The output of this step is a written content map: source type → target type, with field-level mapping notes and a flag on anything ambiguous. Everything downstream depends on it.

Step 2: Design the Schema as Data, Not Layout

The biggest mistake in any migration is replicating the source system’s structure in Sanity. Source CMSes organize content around presentation; Sanity wants structured, queryable data. Model what things are, not what they look like.

The test: if we redesigned the site, would this field name still make sense?

  • threeColumnLayout → fails (what happens when it’s two columns?)
  • features → passes (features are features regardless of layout)

Use the strict definition helpers so the schema is type-safe and the Studio gets proper tooling:

// schemas/article.ts
import { defineType, defineField, defineArrayMember } from "sanity";
import { DocumentTextIcon } from "@sanity/icons";

export const article = defineType({
  name: "article",
  title: "Article",
  type: "document",
  icon: DocumentTextIcon,
  fields: [
    defineField({
      name: "title",
      type: "string",
      validation: (rule) => rule.required(),
    }),
    defineField({
      name: "tags",
      type: "array",
      of: [defineArrayMember({ type: "reference", to: [{ type: "tag" }] })],
    }),
  ],
});

Decide references vs nested objects up front

This modeling decision is hard to change after data lands, so make it deliberately:

ScenarioUse
Blog post authorreference (reusable, independently editable)
Product categoryreference (shared taxonomy)
Page SEO fieldsobject (document-specific, copied not linked)
Hero section contentobject (page-specific)
Call-to-action buttonobject (usually page-specific)

Use a reference when content is reused, needs its own editing interface, or should update everywhere at once. Use a nested object when content is specific to one document and doesn’t make sense on its own.

Add validation before the data arrives

Validation rules are cheapest to add now — they stop malformed source data from ever landing in your clean dataset. Lean on rule.required(), rule.email(), rule.uri(), length constraints, and custom rules for the fields your audit flagged as messy.

Note

Keep a hidden legacyId (or wordpressId, contentfulId, etc.) field on every migrated document. It’s invisible to editors but invaluable for re-running migrations, mapping references between old and new IDs, and debugging discrepancies during validation.

Step 3: Pick a Migration Strategy

Choose the approach that matches your volume and downtime tolerance — and commit to it before writing code, because it changes how you structure everything.

  • Gradual (recommended for large projects): migrate one content type at a time, run old and new in parallel, redirect traffic incrementally. Lowest risk, longest timeline.
  • Complete: migrate everything in one planned maintenance window. Best for small, simple datasets where a short content freeze is acceptable.
  • Hybrid: keep legacy content where it is, author new content in Sanity, consolidate later. Good for phased rollouts with legacy constraints.

Then decide your tooling per migration:

  • defineMigration from sanity/migrate — the built-in framework. Use it for in-dataset transforms (renaming fields, reshaping documents). It handles batching and dry-runs for you.
  • A raw @sanity/client import script — use it for the initial bulk import from an external system, asset uploads, and cross-document reference fixes that defineMigration can’t express.

Tip

Start small regardless of strategy. Migrate a single content type end-to-end first — audit, schema, migrate, validate. The first type surfaces 80% of the edge cases you’d otherwise discover halfway through migrating everything.

Step 4: Back Up and Clone the Dataset

Never let a migration touch production without a restore point and a rehearsal copy. These are two separate things and you want both.

Export a backup — a point-in-time snapshot you can restore from if everything goes wrong:

# Full export including assets — your restore point
npx sanity dataset export production backup-pre-migration.tar.gz

Clone to a staging dataset — a working copy where every rehearsal runs against real data, real volumes, and real edge cases:

# Copy production into a throwaway dataset to rehearse against
npx sanity dataset copy production staging

Run every migration and every validation query against staging until you’re confident. When something breaks — and on the first run, something always does — you reset by re-copying from production, not by manually unwinding damage.

Warning

A dry run is not a backup, and a cloned dataset is not a backup either — both can drift from production while you work. Keep the exported .tar.gz as the canonical restore point, and re-export it immediately before the production run so it reflects the exact state you’re migrating from.

Step 5: Make Migrations Idempotent and Dry-Run Them

The single most important property of a migration is that running it twice does no harm. You will re-run it — after a crash, after a fix, after a partial failure — and it must pick up only the unmigrated documents.

With defineMigration, the filter is what makes it idempotent:

// migrations/rename-title-to-heading/index.ts
import { defineMigration, at, setIfMissing, unset } from "sanity/migrate";

export default defineMigration({
  title: "Rename title to heading",
  documentTypes: ["article"],
  // Only touch documents that still need migrating
  filter: "defined(title) && !defined(heading)",
  migrate: {
    document(doc) {
      return [
        at("heading", setIfMissing(doc.title)),
        at("title", unset()),
      ];
    },
  },
});

defineMigration runs in dry-run mode by default. Preview first, then commit:

# Dry run against staging (default — nothing is written)
npx sanity migration run rename-title-to-heading --dataset staging

# Execute only after reviewing the preview
npx sanity migration run rename-title-to-heading --dataset staging --no-dry-run

If you’re using a raw @sanity/client script instead — for the bulk import or reference fixes — never accumulate every patch into one transaction. Large transactions run out of memory or hit size limits. Batch them:

const BATCH_SIZE = 100;

for (let i = 0; i < documents.length; i += BATCH_SIZE) {
  const batch = documents.slice(i, i + BATCH_SIZE);
  const transaction = client.transaction();

  for (const doc of batch) {
    transaction.patch(doc._id, {
      set: { heading: doc.title },
      unset: ["title"],
    });
  }

  await transaction.commit();
  console.log(`Batch ${Math.floor(i / BATCH_SIZE) + 1} committed`);
}

Note

Asset uploads are naturally idempotent — Sanity deduplicates by SHA1 of file content, so re-uploading the same bytes returns the same asset ID. Persist your URL-to-asset-ID map to disk after every upload so a crash at asset #4,200 of 5,000 resumes instead of restarting.

Step 6: Validate Against Staging

Before pointing anything at production, prove the migration worked on the clone. Run GROQ audits and compare every count against the numbers from your Step 1 audit.

// Did everything come across? Compare to your source count.
count(*[_type == "article" && defined(legacyId)])

// Documents missing body content
*[_type == "article" && (!defined(body) || length(body) == 0)]{
  _id, title, legacyId
}

// Broken or unresolved references
*[_type == "article" && count(categories[!defined(@->_id)]) > 0]{
  _id, title, legacyId
}

// Images that didn't resolve to an asset
*[_type == "article" && defined(featuredImage) && !defined(featuredImage.asset)]{
  _id, title
}

// Orphaned assets created by failed dry runs
*[_type == "sanity.imageAsset" && count(*[references(^._id)]) == 0]{
  _id, originalFilename, size
}

Any discrepancy points to a real edge case — malformed source data, unmapped references, hotlinked images that 404’d. Fix the script, re-copy the dataset, re-run. Repeat until the counts match and the spot-checks look right.

Warning

Frontend gotcha to verify now, not on launch day: Sanity does not include image metadata.lqip or dimensions in queries automatically, and it ignores hotspot/crop unless you project them explicitly. Confirm your queries request these before go-live, or you’ll ship layout shift and center-cropped images.

Step 7: Write a Rollback and Go-Live Plan

The migration is a five-minute command. The plan around it is what makes it safe. Write this down — don’t keep it in your head — and make sure everyone running the migration has it.

Your go-live plan should answer:

  • Content freeze: when do editors stop touching the source system, and for how long? Late edits during migration are silently lost.
  • Cutover order: taxonomies and assets first (documents reference them), then documents, then reference fixes.
  • Ownership: who runs the production migration, and who validates immediately after?
  • Final backup: re-export production right before the run, so your restore point is current.
  • Rollback trigger: what specific failure (counts off by more than X, broken references, missing assets) aborts the cutover?
  • Restore procedure: the exact command to bring production back, rehearsed at least once on staging:
# Tested rollback: restore from the pre-migration export
npx sanity dataset import backup-pre-migration.tar.gz production --replace
  • Redirects: for platform migrations, map old URLs to new paths so search rankings and bookmarks survive.

Tip

Rehearse the rollback, not just the migration. Run the restore command against your staging dataset once so you know it works and how long it takes. A rollback plan you’ve never executed is a hope, not a plan.

The Pre-Migration Checklist

  • Inventory every source content type, field, taxonomy, and asset — with real counts from the API/database
  • Write a source-to-target content map, flagging ambiguous fields
  • Design schemas as structured data, not layout (defineType/defineField/defineArrayMember)
  • Decide references vs nested objects for every relationship
  • Add validation rules before any data lands
  • Keep a hidden legacyId field on migrated documents
  • Choose a migration strategy (gradual / complete / hybrid) and tooling (defineMigration vs client script)
  • Export a production backup as a restore point
  • Clone production to a staging dataset to rehearse against
  • Make every migration idempotent with a filter (or a persisted asset map)
  • Batch large @sanity/client transactions
  • Dry-run every migration before --no-dry-run
  • Validate on staging with GROQ — compare counts, check refs, assets, alt text
  • Confirm frontend queries project lqip, dimensions, hotspot, and crop
  • Plan the content freeze, cutover order, and ownership
  • Re-export production immediately before go-live
  • Write and rehearse the rollback command on staging
  • Set up redirects from old URLs to new paths

Work through this list and the migration becomes what it should be: anticlimactic. Every surprise has already happened on staging, every count has already been checked, and the one command you run in production is one you’ve run a dozen times before.