> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).

# 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](https://www.sanity.io/docs/content-lake/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](https://www.sanity.io/docs/content-lake/drafts-and-versions) and [Perspectives for Content Lake](https://www.sanity.io/docs/content-lake/perspectives).

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](https://www.sanity.io/docs/visual-editing/introduction-to-visual-editing) and [Preview and page building](https://www.sanity.io/docs/user-guides/preview-and-page-building).

> [!NOTE]
> Content Releases is available on Enterprise plans. On the Growth plan, use [scheduled drafts](https://www.sanity.io/docs/studio/scheduled-drafts) to schedule a single document. For how releases and their version documents work through the APIs, see [Content release document flow](https://www.sanity.io/docs/content-lake/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](https://www.sanity.io/docs/studio/environment-variables) and [Workspaces](https://www.sanity.io/docs/studio/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](https://www.sanity.io/docs/blueprints/manage-environments).

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](https://www.sanity.io/docs/apis-and-sdks/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`:

**npm**

```shell
# 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
```

**pnpm**

```shell
# 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
```

**yarn**

```shell
# 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
```

**bun**

```shell
# 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](https://www.sanity.io/docs/content-lake/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 with `Target dataset "<name>" already exists`. Use `--skip-history` to speed up large copies and `--skip-content-releases` to leave content releases out. Cloud Clone is part of Advanced Dataset Management on Enterprise plans. See [How to use Cloud Clone for datasets](https://www.sanity.io/docs/content-lake/how-to-use-cloud-clone-for-datasets).
- **Export and import** (`sanity datasets export` and `sanity datasets import`): works on every plan and can write into an existing dataset. Exports include drafts and asset files unless you pass `--no-drafts` or `--no-assets`, and count against your API quota. See [Export a dataset](https://www.sanity.io/docs/content-lake/exporting-data) and [Import data](https://www.sanity.io/docs/content-lake/importing-data).
- **Dataset aliases** (hot swapping): an alias such as `~production` points 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](https://www.sanity.io/docs/content-lake/how-to-use-hot-swapping-for-datasets).

**npm**

```shell
# 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
```

**pnpm**

```shell
# 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
```

**yarn**

```shell
# 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
```

**bun**

```shell
# 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.
- `--replace` overwrites documents that have the same ID. `--missing` skips 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.

> [!WARNING]
> 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](https://www.sanity.io/docs/content-lake/schema-and-content-migrations).

**npm**

```shell
npx sanity datasets export development seed.tar.gz --types siteSettings,navigation
npx sanity datasets import seed.tar.gz --dataset production --missing
```

**pnpm**

```shell
pnpm dlx sanity datasets export development seed.tar.gz --types siteSettings,navigation
pnpm dlx sanity datasets import seed.tar.gz --dataset production --missing
```

**yarn**

```shell
yarn dlx sanity datasets export development seed.tar.gz --types siteSettings,navigation
yarn dlx sanity datasets import seed.tar.gz --dataset production --missing
```

**bun**

```shell
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:

**schemaTypes/fields/subtitle.ts**

```typescript
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](https://www.sanity.io/docs/studio/validation) and [Environment variables](https://www.sanity.io/docs/studio/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.resource` to the source dataset and `event.filter` to the document types you want to mirror, so unrelated documents aren't copied. To propagate deletions, use `on: ['create', 'update', 'delete']`.
- **Leave out unpublished work:** with `create`, `update`, and `delete`, drafts and release versions don't trigger the function unless you set `includeDrafts` or `includeAllVersions`.
- **Write to the target dataset:** create the client from `context.clientOptions`, override `dataset` with the target, and make sure the function's token can write to that dataset. See [Configure @sanity/client in Functions](https://www.sanity.io/docs/functions/functions-js-client).
- **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](https://www.sanity.io/docs/functions/functions-introduction) and [Create a Document Function](https://www.sanity.io/docs/functions/function-quickstart).

