Development, staging, and production environments
How drafts, content releases, previews, and separate datasets fit together when you test schema and content changes before production.
An environment in Sanity is a dataset plus the studio and applications configured to read from it. Separate development and staging datasets are useful while you build: they let you test complex schema changes, content migrations, and application code against realistic content without risking production.
For most content and schema changes, you don't need a separate dataset. We recommend content releases instead: editors stage changes to a group of documents in the production dataset, preview them, and publish them together. Additive schema changes, such as a new field, can ship to production, and editors fill in the new field in a release. Reserve separate datasets for development and for schema changes that rename, remove, or restructure existing content. This article explains how drafts, releases, separate datasets, and the tools that copy content between datasets fit together, and which one fits which change.
Schema and content move in opposite directions
Your schema lives in code. Schema types, validation rules, studio configuration, and the queries in your applications are files in your repository. You promote them like any other code: from development, through staging, to production.
Your content lives in a dataset. Content Lake stores documents without enforcing a schema, so a studio with a newer or older schema can read the same documents. For more on this, see Schema validation and the Content Lake.
That split leads to a common pattern:
- Schema and code move up: development, then staging, then production.
- Content moves down: copy production content into development and staging datasets so you test against real documents.
- Editors author in production: drafts and content releases keep unpublished work out of your live application until someone publishes it.
Moving content up from a development dataset into production is the exception. The section on moving content from development into production covers how to do it without overwriting editors' work.
Review content changes in a single dataset
For most content changes, we recommend reviewing them in the production dataset rather than in a second dataset. Unpublished work lives alongside published documents in the same dataset:
- Drafts hold unpublished edits to one document.
- Content release versions hold unpublished edits to a group of documents that publish together.
Content Lake doesn't serve drafts or versions to unauthenticated requests, and the published perspective excludes both. A production application that queries with the published perspective never shows them. See Drafts and versions and Perspectives for Content Lake.
To review those changes in your front end, use visual editing. The Presentation Tool shows drafts, or a selected release, in your application inside the studio. Editors can also share a preview link with people who don't use the studio. See Visual editing and Preview and page building.
Content Releases is available on Enterprise plans. On the Growth plan, use scheduled drafts to schedule a single document. For how releases and their version documents work through the APIs, see Content release document flow.
Test schema and code changes in a separate dataset
Use a separate dataset while you develop, and when a change can break something for editors or for your live application, such as:
- Content migrations that rewrite existing documents.
- Schema changes that rename, remove, or restructure fields.
- New application code or queries that need realistic content to test against.
- Webhooks, functions, or other integrations that write to the dataset.
Point each environment at its own dataset. In the studio, read the dataset name from an environment variable, or define one workspace per dataset. In your applications, set dataset in the client configuration for each deployment. See Environment variables and Workspaces.
A dataset holds content, but an environment also depends on project resources such as CORS origins, webhooks, robot tokens, and functions. To define those resources in one blueprint file and deploy them to each environment as its own stack, see Manage environments with Blueprints.
Deployed schemas are stored per dataset. sanity schemas deploy deploys each workspace's schema to that workspace's dataset, so deploy again after you change the schema an environment uses. See Schema deployment.
Run content migrations against a non-production dataset first. sanity migrations run performs a dry run unless you pass --no-dry-run, and --dataset selects the dataset. The command requires --project whenever you pass --dataset:
# Dry run against staging and review the patches npx sanity migrations run MIGRATION_ID --project YOUR_PROJECT_ID --dataset staging # Apply to staging, test, then apply to production npx sanity migrations run MIGRATION_ID --project YOUR_PROJECT_ID --dataset staging --no-dry-run npx sanity migrations run MIGRATION_ID --project YOUR_PROJECT_ID --dataset production --no-dry-run
# Dry run against staging and review the patches pnpm dlx sanity migrations run MIGRATION_ID --project YOUR_PROJECT_ID --dataset staging # Apply to staging, test, then apply to production pnpm dlx sanity migrations run MIGRATION_ID --project YOUR_PROJECT_ID --dataset staging --no-dry-run pnpm dlx sanity migrations run MIGRATION_ID --project YOUR_PROJECT_ID --dataset production --no-dry-run
# Dry run against staging and review the patches yarn dlx sanity migrations run MIGRATION_ID --project YOUR_PROJECT_ID --dataset staging # Apply to staging, test, then apply to production yarn dlx sanity migrations run MIGRATION_ID --project YOUR_PROJECT_ID --dataset staging --no-dry-run yarn dlx sanity migrations run MIGRATION_ID --project YOUR_PROJECT_ID --dataset production --no-dry-run
# Dry run against staging and review the patches bunx sanity migrations run MIGRATION_ID --project YOUR_PROJECT_ID --dataset staging # Apply to staging, test, then apply to production bunx sanity migrations run MIGRATION_ID --project YOUR_PROJECT_ID --dataset staging --no-dry-run bunx sanity migrations run MIGRATION_ID --project YOUR_PROJECT_ID --dataset production --no-dry-run
Replace MIGRATION_ID with the ID of a migration in your project, and <your-project-id> with your project ID. For a full checklist, see Important considerations for schema and content migrations.
Copy production content into another dataset
There are three techniques for getting production content into another dataset in the same project. They differ in plan availability and in what happens to the target dataset.
- Cloud Clone (
sanity datasets copy): copies a dataset inside Sanity's infrastructure into a new dataset. The target can't exist yet. If it does, the command fails withTarget dataset "<name>" already exists. Use--skip-historyto speed up large copies and--skip-content-releasesto leave content releases out. Cloud Clone is part of Advanced Dataset Management on Enterprise plans. See How to use Cloud Clone for datasets. - Export and import (
sanity datasets exportandsanity datasets import): works on every plan and can write into an existing dataset. Exports include drafts and asset files unless you pass--no-draftsor--no-assets, and count against your API quota. See Export a dataset and Import data. - Dataset aliases (hot swapping): an alias such as
~productionpoints at a dataset, and applications can query through it. Prepare content in a copy, then link the alias to the copy when it's ready. Aliases are read-only: studios, webhooks, functions, dataset-scoped roles, and the Live Content API need the real dataset name, so each swap still means updating and redeploying them. Reserve hot swapping for recovery and for breaking schema or content migrations. Aliases are also part of Advanced Dataset Management. See How to use Hot Swapping for datasets.
# Cloud Clone: copy production into a new dataset npx sanity datasets copy production staging-refresh --skip-history # Export and import: refresh an existing staging dataset npx sanity datasets export production production.tar.gz npx sanity datasets import production.tar.gz --dataset staging --replace
# Cloud Clone: copy production into a new dataset pnpm dlx sanity datasets copy production staging-refresh --skip-history # Export and import: refresh an existing staging dataset pnpm dlx sanity datasets export production production.tar.gz pnpm dlx sanity datasets import production.tar.gz --dataset staging --replace
# Cloud Clone: copy production into a new dataset yarn dlx sanity datasets copy production staging-refresh --skip-history # Export and import: refresh an existing staging dataset yarn dlx sanity datasets export production production.tar.gz yarn dlx sanity datasets import production.tar.gz --dataset staging --replace
# Cloud Clone: copy production into a new dataset bunx sanity datasets copy production staging-refresh --skip-history # Export and import: refresh an existing staging dataset bunx sanity datasets export production production.tar.gz bunx sanity datasets import production.tar.gz --dataset staging --replace
When you import into a dataset that already has content, these rules decide what happens:
- By default, the import fails if any document ID in the file already exists in the target dataset.
--replaceoverwrites documents that have the same ID.--missingskips them.- Import never deletes documents. Documents in the target that aren't in the file stay where they are. For an exact copy, import into a new, empty dataset.
- System documents, whose IDs start with
_., are skipped unless you pass--allow-system-documents. Content release documents are imported either way.
Each copy counts toward your plan's dataset limit. Content releases copied into another dataset count toward your organization's release quota. To leave releases out of a Cloud Clone copy, pass --skip-content-releases.
Move content from development into production
Sometimes content created during development belongs in production: seed documents for a new document type, or settings documents that a new feature depends on. Treat this as a one-off transfer, not a sync.
- Export only the document types you need with
--types, then import into production with--missing, so the import never overwrites a document that already exists in production. - Include every document the imported documents reference, and the assets they use, in the same export.
- To change documents that already exist in production, write a content migration and run it against production instead of importing over editors' work. See Migrating your schema and content.
npx sanity datasets export development seed.tar.gz --types siteSettings,navigation npx sanity datasets import seed.tar.gz --dataset production --missing
pnpm dlx sanity datasets export development seed.tar.gz --types siteSettings,navigation pnpm dlx sanity datasets import seed.tar.gz --dataset production --missing
yarn dlx sanity datasets export development seed.tar.gz --types siteSettings,navigation yarn dlx sanity datasets import seed.tar.gz --dataset production --missing
bunx sanity datasets export development seed.tar.gz --types siteSettings,navigation bunx sanity datasets import seed.tar.gz --dataset production --missing
Replace siteSettings,navigation with a comma-separated list of your own document types.
Phase in schema changes on production
Most schema changes are additive, such as a new field or document type, and don't need a separate dataset. Deploy the studio with the new schema, fill in the new fields in a content release, and publish the release when your front end is ready to use them. To ship a new field before editors start using it, hide it with the hidden property and read a flag from a studio environment variable, so each deployment decides whether to show it.
Hidden fields are still validated. A validation rule receives context.hidden, which is true when the field or any of its ancestors is hidden. Return rule.skip() in that case, so a hidden required field doesn't block publishing:
import {defineField} from 'sanity'
const showNewFields = process.env.SANITY_STUDIO_SHOW_NEW_FIELDS === 'true'
export const subtitle = defineField({
name: 'subtitle',
title: 'Subtitle',
type: 'string',
hidden: !showNewFields,
validation: (rule, context) => (context?.hidden ? rule.skip() : rule.required()),
})Set SANITY_STUDIO_SHOW_NEW_FIELDS=true in development and staging, and leave it unset in production until you're ready. The studio replaces environment variables at build time, so redeploy the studio after you change one. See Validation and Environment variables.
Hiding a field only changes the studio form. Values already stored in documents stay in the dataset and still appear in query results.
Mirror documents between datasets with Functions
Sanity has no built-in sync between datasets. If you need one dataset to follow another continuously, such as a staging dataset that receives every published change from production, you can write a document function that listens for changes in the source dataset and writes each document to the target dataset. You own that logic and its edge cases:
- Scope the trigger: set
event.resourceto the source dataset andevent.filterto the document types you want to mirror, so unrelated documents aren't copied. To propagate deletions, useon: ['create', 'update', 'delete']. - Leave out unpublished work: with
create,update, anddelete, drafts and release versions don't trigger the function unless you setincludeDraftsorincludeAllVersions. - Write to the target dataset: create the client from
context.clientOptions, overridedatasetwith the target, and make sure the function's token can write to that dataset. See Configure @sanity/client in Functions. - Bring the assets: image and file fields reference asset documents in the source dataset. Writing a document whose asset doesn't exist in the target fails with an error that the document
references non-existent document. Upload the asset to the target dataset first. - Resolve references: any strong reference to a document that isn't in the target dataset fails the same way. Mirror referenced documents first, or skip documents whose references don't resolve yet.
- Keep it one-way: a function that writes to the dataset it listens to can trigger itself. Functions stop a chain after 16 invocations, but two-way mirroring is still fragile. Edits made directly in the target dataset are overwritten by the next mirrored change.
To get started, see Functions and Create a Document Function.