---
title: State migrations
url: /docs/iac/guides/building-extending/components/state-migrations/
---


State migrations let you evolve a [component's](/docs/iac/concepts/components/) internal resources while preserving the infrastructure it already manages. Attach a migration callback to the component to translate its saved [state](/docs/iac/concepts/state-and-backends/) and the state of its children into the representation expected by the new implementation. Pulumi runs the migration before calculating resource changes.

> **Note:** The state migrations API is **experimental** and may change.

Share feedback in the [component state migrations discussion](https://github.com/pulumi/pulumi/discussions/24799).

## When to use a state migration

Use a state migration when a component upgrade needs to translate its children's saved properties or reorganize resources that already represent the same physical infrastructure. Keep the migration in the component implementation so consumers can upgrade the component without editing their stack state manually.

For simpler changes, use the corresponding resource option:

| Change | Mechanism |
| --- | --- |
| Rename a resource or change its parent while retaining compatible state | [Aliases](/docs/iac/concepts/resources/options/aliases/) |
| Transform resource inputs or options as the program registers resources | [Transforms](/docs/iac/concepts/resources/options/transforms/) |
| Bring an existing cloud resource under Pulumi management | [Import](/docs/iac/guides/migration/import/) |
| Translate saved properties or merge state entries during a component upgrade | State migrations |

A migration rewrites state only. It does not create, import, update, or delete cloud resources. After the migration, Pulumi compares the updated program with the migrated state and performs any remaining provider operations normally. An incorrect translation can still lead to an unwanted update or replacement, so review the preview before applying it.

## Write a migration callback

A component state migration is a pure function that transforms the saved state of a component and its children into the state expected by a new component version. It returns the updated state records and successor mappings for any changed URNs, or no result if no migration is needed.

The [option reference](/docs/iac/concepts/resources/options/statemigrations/#callback-api) explains the callback API. The state entries use the [checkpoint resource format](https://pulumi-developer-docs.readthedocs.io/latest/docs/references/deployment-schema.html#pulumi-resource-state), including fields such as `urn`, `type`, `id`, `parent`, `provider`, `inputs`, and `outputs`.

Return the **complete** migrated subtree, including the component and unchanged descendants. For every resource in the callback's input, do exactly one of the following:

- Return it under the same URN, with any desired state changes.
- Omit its old URN and map that URN to a successor present in the returned state.

Several old URNs can map to one successor. A resource cannot both remain in the result and have a successor mapping, and a mapping cannot point to a resource outside the result. Express state renames through successors rather than adding aliases to returned checkpoint entries.

Write callbacks that recognize the old representation and return no result when it's already migrated. Keep earlier callbacks when publishing later migrations so users can upgrade from older component versions. Reject unexpected state rather than guessing how to translate it.

### Preserve identity and secrets

For managed custom resources, a successor must preserve the physical ID, provider reference, extension reference, ownership, and lifecycle flags. When merging resources, preserve `protect` and `retainOnDelete` if either predecessor has them. Provider resource entries must remain unchanged. A migration cannot introduce a new managed physical object by inventing an ID.

The returned state must form a valid subtree, with unique URNs and resolvable structural references. When changing a resource type, update both its `type` field and the type in its URN. Preserve fields you are not intentionally translating, and copy maps before editing them.

> **Warning:** Callbacks receive decrypted secret values inside their serialized secret envelopes. Preserve those envelopes and do not log state or include secret values in errors. Treat a callback as a pure state transformation: do not register resources, invoke providers, perform other Pulumi runtime operations, or wait for unresolved outputs.

## Example: upgrade a versioned bucket component

Consider a storage component with these two direct children:

- `aws:s3/bucketV2:BucketV2`, representing an S3 bucket.
- `aws:s3/bucketVersioningV2:BucketVersioningV2`, managing versioning for that same bucket.

The updated component registers one `aws:s3/bucket:Bucket` with inline versioning. Both old state entries have the same physical bucket ID and provider reference, which allows them to name the same successor.

The migration preserves the component and other descendants, converts the bucket's type and properties, and removes the separate versioning entry. Both old URNs map to the new bucket URN:

| Prior state entry | Returned state entry | Successor mapping |
| --- | --- | --- |
| Component | Unchanged component | None |
| `BucketV2` | `Bucket` with the same physical ID | Old bucket URN → new bucket URN |
| `BucketVersioningV2` | Omitted | Versioning URN → new bucket URN |

### Translate the saved state

The callbacks below implement this migration. Each translates both inputs and outputs. Each uses the separate versioning resource's outputs as the source of versioning settings and converts the bucket's plural singleton lists to the new resource's singular objects. These conversions are specific to the two AWS resource schemas, they are not a general conversion between arbitrary resource types.

<!-- chooser: language -->

<!-- option: typescript -->
```typescript

```typescript
import type * as pulumi from "@pulumi/pulumi";

export const componentType = "example:storage:VersionedBucket";
const oldBucketType = "aws:s3/bucketV2:BucketV2";
const bucketType = "aws:s3/bucket:Bucket";
const versioningType = "aws:s3/bucketVersioningV2:BucketVersioningV2";

function stateString(state: Record<string, any>, key: string): string {
const value = state[key];
if (typeof value !== "string" || value === "") {
throw new Error(`State resource has missing or invalid ${key}`);
}
return value;
}

export function migrateBucketState(args: pulumi.StateMigrationArgs): pulumi.StateMigrationResult | undefined {
if (args.oldState.length === 0) {
return undefined;
}
const root = args.oldState[0];
if (root.type !== componentType) {
throw new Error("Expected a VersionedBucket migration root");
}

// Find the bucket and versioning sidecar directly parented to this component.
let index = -1;
let sidecarIndex = -1;
args.oldState.forEach((state, i) => {
if (state.parent !== root.urn) {
return;
}
if (state.type === oldBucketType || state.type === bucketType) {
if (index !== -1) {
throw new Error("Expected only one bucket");
}
index = i;
} else if (state.type === versioningType) {
if (sidecarIndex !== -1) {
throw new Error("Expected only one versioning sidecar");
}
sidecarIndex = i;
}
});
// Leave fresh or already-migrated state unchanged.
if (sidecarIndex === -1 && (index === -1 || args.oldState[index].type === bucketType)) {
return undefined;
}
if (index === -1 || sidecarIndex === -1 || args.oldState[index].type !== oldBucketType) {
throw new Error("Expected BucketV2 and its versioning sidecar");
}
const oldBucket = args.oldState[index];
const sidecar = args.oldState[sidecarIndex];
// Both state entries must refer to the same physical bucket and provider.
for (const key of ["id", "provider"]) {
if (sidecar[key] !== stateString(oldBucket, key)) {
throw new Error(`Bucket and sidecar must have the same ${key}`);
}
}
// Only migrate the versioning configuration supported by this example.
const configuration = sidecar.outputs?.versioningConfiguration;
if (configuration?.status !== "Enabled" || configuration?.mfaDelete !== "Disabled") {
throw new Error("This example requires enabled versioning without MFA delete");
}
const sidecarURN = stateString(sidecar, "urn");

// Copy the bucket state and change its logical type in both the URN and
// type field, preserving its physical ID and other metadata.
const bucket = { ...oldBucket };
const oldURN = stateString(bucket, "urn");
const oldType = `$${oldBucketType}::`;
if (!oldURN.includes(oldType)) {
throw new Error("Bucket URN does not match BucketV2");
}
const newURN = oldURN.replace(oldType, `$${bucketType}::`);
bucket.urn = newURN;
bucket.type = bucketType;

// Translate both saved inputs and observed outputs to the new schema.
for (const field of ["inputs", "outputs"]) {
const oldProperties = bucket[field];
if (oldProperties === null || typeof oldProperties !== "object" || Array.isArray(oldProperties)) {
throw new Error(`Expected bucket ${field} object`);
}
const properties = { ...oldProperties };
delete properties.__pulumi_raw_state_delta;
delete properties.versionings;
// Populate the new bucket's inline versioning settings from the old
// versioning sidecar, which managed this configuration before migration.
properties.versioning = {
enabled: configuration.status === "Enabled",
mfaDelete: configuration.mfaDelete === "Enabled",
};

// BucketV2 uses plural singleton lists; Bucket uses singular objects.
for (const [plural, singular] of Object.entries({
loggings: "logging",
replicationConfigurations: "replicationConfiguration",
serverSideEncryptionConfigurations: "serverSideEncryptionConfiguration",
websites: "website",
})) {
const blocks = properties[plural];
delete properties[plural];
if (blocks === undefined || blocks === null) {
continue;
}
if (!Array.isArray(blocks) || blocks.length > 1) {
throw new Error(`Expected at most one ${plural} block`);
}
if (blocks.length === 1) {
properties[singular] = blocks[0];
}
}
bucket[field] = properties;
}

// Preserve the rest of the subtree and remove the separate versioning entry.
// Map both old URNs to the new bucket so Pulumi can rewrite references to them.
const newState = args.oldState.slice();
newState[index] = bucket;
newState.splice(sidecarIndex, 1);
return {
newState,
successors: { [oldURN]: newURN, [sidecarURN]: newURN },
};
}

```

```

```

<!-- /option -->

<!-- option: python -->
```python

```python
from typing import Any

import pulumi

COMPONENT_TYPE = "example:storage:VersionedBucket"
OLD_BUCKET_TYPE = "aws:s3/bucketV2:BucketV2"
BUCKET_TYPE = "aws:s3/bucket:Bucket"
VERSIONING_TYPE = "aws:s3/bucketVersioningV2:BucketVersioningV2"

def state_string(state: dict[str, Any], key: str) -> str:
    value = state.get(key)
    if not isinstance(value, str) or not value:
        raise ValueError(f"State resource has missing or invalid {key}")
    return value

def migrate_bucket_state(
    args: pulumi.StateMigrationArgs,
) -> pulumi.StateMigrationResult | None:
    if not args.old_state:
        return None
    root = args.old_state[0]
    if root.get("type") != COMPONENT_TYPE:
        raise ValueError("Expected a VersionedBucket migration root")

    # Find the bucket and versioning sidecar directly parented to this component.
    index, sidecar_index = -1, -1
    for i, state in enumerate(args.old_state):
        if state.get("parent") != root.get("urn"):
            continue
        if state.get("type") in (OLD_BUCKET_TYPE, BUCKET_TYPE):
            if index != -1:
                raise ValueError("Expected only one bucket")
            index = i
        elif state.get("type") == VERSIONING_TYPE:
            if sidecar_index != -1:
                raise ValueError("Expected only one versioning sidecar")
            sidecar_index = i
    # Leave fresh or already-migrated state unchanged.
    if sidecar_index == -1 and (
        index == -1 or args.old_state[index].get("type") == BUCKET_TYPE
    ):
        return None
    if index == -1 or sidecar_index == -1 or args.old_state[index].get("type") != OLD_BUCKET_TYPE:
        raise ValueError("Expected BucketV2 and its versioning sidecar")
    old_bucket, sidecar = args.old_state[index], args.old_state[sidecar_index]

    # Both state entries must refer to the same physical bucket and provider.
    for key in ("id", "provider"):
        if sidecar.get(key) != state_string(old_bucket, key):
            raise ValueError(f"Bucket and sidecar must have the same {key}")
    # Only migrate the versioning configuration supported by this example.
    outputs = sidecar.get("outputs")
    configuration = outputs.get("versioningConfiguration") if isinstance(outputs, dict) else None
    if (
        not isinstance(configuration, dict)
        or configuration.get("status") != "Enabled"
        or configuration.get("mfaDelete") != "Disabled"
    ):
        raise ValueError("This example requires enabled versioning without MFA delete")
    sidecar_urn = state_string(sidecar, "urn")

    # Copy the bucket state and change its logical type in both the URN and
    # type field, preserving its physical ID and other metadata.
    bucket = dict(old_bucket)
    old_urn = state_string(bucket, "urn")
    old_type = f"${OLD_BUCKET_TYPE}::"
    if old_type not in old_urn:
        raise ValueError("Bucket URN does not match BucketV2")
    new_urn = old_urn.replace(old_type, f"${BUCKET_TYPE}::", 1)
    bucket["urn"], bucket["type"] = new_urn, BUCKET_TYPE

    # Translate both saved inputs and observed outputs to the new schema.
    for field in ("inputs", "outputs"):
        old_properties = bucket.get(field)
        if not isinstance(old_properties, dict):
            raise ValueError(f"Expected bucket {field} object")
        properties = dict(old_properties)
        properties.pop("__pulumi_raw_state_delta", None)
        properties.pop("versionings", None)
        # Populate the new bucket's inline versioning settings from the old
        # versioning sidecar, which managed this configuration before migration.
        properties["versioning"] = {
            "enabled": configuration["status"] == "Enabled",
            "mfaDelete": configuration["mfaDelete"] == "Enabled",
        }

        # BucketV2 uses plural singleton lists; Bucket uses singular objects.
        for plural, singular in {
            "loggings": "logging",
            "replicationConfigurations": "replicationConfiguration",
            "serverSideEncryptionConfigurations": "serverSideEncryptionConfiguration",
            "websites": "website",
        }.items():
            blocks = properties.pop(plural, None)
            if blocks is None:
                continue
            if not isinstance(blocks, list) or len(blocks) > 1:
                raise ValueError(f"Expected at most one {plural} block")
            if blocks:
                properties[singular] = blocks[0]
        bucket[field] = properties

    # Preserve the rest of the subtree and remove the separate versioning entry.
    # Map both old URNs to the new bucket so Pulumi can rewrite references to them.
    new_state = list(args.old_state)
    new_state[index] = bucket
    del new_state[sidecar_index]
    return pulumi.StateMigrationResult(
        new_state=new_state,
        successors={old_urn: new_urn, sidecar_urn: new_urn},
    )

```

```

```

<!-- /option -->

<!-- option: go -->
```go

```go
package main

import (
	"context"
	"fmt"
	"maps"
	"slices"
	"strings"

	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

const (
	componentType  = "example:storage:VersionedBucket"
	oldBucketType  = "aws:s3/bucketV2:BucketV2"
	bucketType     = "aws:s3/bucket:Bucket"
	versioningType = "aws:s3/bucketVersioningV2:BucketVersioningV2"
)

func stateString(state map[string]any, key string) (string, error) {
	value, ok := state[key].(string)
	if !ok || value == "" {
		return "", fmt.Errorf("state resource has missing or invalid %s", key)
	}
	return value, nil
}

func migrateBucketState(_ context.Context, args *pulumi.StateMigrationArgs) (*pulumi.StateMigrationResult, error) {
	if len(args.OldState) == 0 {
		return nil, nil
	}
	root := args.OldState[0]
	if root["type"] != componentType {
		return nil, fmt.Errorf("expected a VersionedBucket migration root")
	}

	// Find the bucket and versioning sidecar directly parented to this component.
	index, sidecarIndex := -1, -1
	for i, state := range args.OldState {
		if state["parent"] != root["urn"] {
			continue
		}
		switch state["type"] {
		case oldBucketType, bucketType:
			if index != -1 {
return nil, fmt.Errorf("expected only one bucket")
			}
			index = i
		case versioningType:
			if sidecarIndex != -1 {
return nil, fmt.Errorf("expected only one versioning sidecar")
			}
			sidecarIndex = i
		}
	}
	// Leave fresh or already-migrated state unchanged.
	if sidecarIndex == -1 && (index == -1 || args.OldState[index]["type"] == bucketType) {
		return nil, nil
	}
	if index == -1 || sidecarIndex == -1 || args.OldState[index]["type"] != oldBucketType {
		return nil, fmt.Errorf("expected BucketV2 and its versioning sidecar")
	}
	oldBucket, sidecar := args.OldState[index], args.OldState[sidecarIndex]
	// Both state entries must refer to the same physical bucket and provider.
	for _, key := range []string{"id", "provider"} {
		value, err := stateString(oldBucket, key)
		if err != nil {
			return nil, err
		}
		if sidecar[key] != value {
			return nil, fmt.Errorf("bucket and sidecar must have the same %s", key)
		}
	}
	// Only migrate the versioning configuration supported by this example.
	outputs, _ := sidecar["outputs"].(map[string]any)
	configuration, _ := outputs["versioningConfiguration"].(map[string]any)
	if configuration["status"] != "Enabled" || configuration["mfaDelete"] != "Disabled" {
		return nil, fmt.Errorf("this example requires enabled versioning without MFA delete")
	}
	sidecarURN, err := stateString(sidecar, "urn")
	if err != nil {
		return nil, err
	}

	// Copy the bucket state and change its logical type in both the URN and
	// type field, preserving its physical ID and other metadata.
	bucket := maps.Clone(args.OldState[index])
	oldURN, err := stateString(bucket, "urn")
	if err != nil {
		return nil, err
	}
	oldType := "$" + oldBucketType + "::"
	if !strings.Contains(oldURN, oldType) {
		return nil, fmt.Errorf("bucket URN does not match BucketV2")
	}
	newURN := strings.Replace(oldURN, oldType, "$"+bucketType+"::", 1)
	bucket["urn"], bucket["type"] = newURN, bucketType

	// Translate both saved inputs and observed outputs to the new schema.
	for _, field := range []string{"inputs", "outputs"} {
		oldProperties, ok := bucket[field].(map[string]any)
		if !ok {
			return nil, fmt.Errorf("expected bucket %s object", field)
		}
		properties := maps.Clone(oldProperties)
		delete(properties, "__pulumi_raw_state_delta")
		delete(properties, "versionings")
		// Populate the new bucket's inline versioning settings from the old
		// versioning sidecar, which managed this configuration before migration.
		properties["versioning"] = map[string]any{
			"enabled":   configuration["status"] == "Enabled",
			"mfaDelete": configuration["mfaDelete"] == "Enabled",
		}

		// BucketV2 uses plural singleton lists; Bucket uses singular objects.
		for plural, singular := range map[string]string{
			"loggings":                           "logging",
			"replicationConfigurations":          "replicationConfiguration",
			"serverSideEncryptionConfigurations": "serverSideEncryptionConfiguration",
			"websites":                           "website",
		} {
			value := properties[plural]
			delete(properties, plural)
			if value == nil {
continue
			}
			blocks, ok := value.([]any)
			if !ok || len(blocks) > 1 {
return nil, fmt.Errorf("expected at most one %s block", plural)
			}
			if len(blocks) == 1 {
properties[singular] = blocks[0]
			}
		}
		bucket[field] = properties
	}

	// Preserve the rest of the subtree and remove the separate versioning entry.
	// Map both old URNs to the new bucket so Pulumi can rewrite references to them.
	newState := slices.Clone(args.OldState)
	newState[index] = bucket
	newState = slices.Delete(newState, sidecarIndex, sidecarIndex+1)
	return &pulumi.StateMigrationResult{
		NewState:   newState,
		Successors: map[string]string{oldURN: newURN, sidecarURN: newURN},
	}, nil
}

```

```

```

<!-- /option -->

<!-- /chooser -->

### Register the migration

The component constructor attaches the callback and registers the new bucket as its child.

<!-- chooser: language -->

<!-- option: typescript -->
```typescript

```typescript
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
import { componentType, migrateBucketState } from "./migration";

export class VersionedBucket extends pulumi.ComponentResource {
    readonly bucketName: pulumi.Output<string>;
    readonly bucketId: pulumi.Output<string>;
    readonly bucketUrn: pulumi.Output<string>;

    constructor(name: string, bucketName: string, opts?: pulumi.ComponentResourceOptions) {
        super(componentType, name, {}, pulumi.mergeOptions(opts, {
            stateMigrations: [migrateBucketState],
        }));

        const bucket = new aws.s3.Bucket(name, {
            bucket: bucketName,
            forceDestroy: true,
            tags: {
                example: "s3-bucket-state-migration",
                "managed-by": "pulumi",
            },
            versioning: { enabled: true },
        }, { parent: this });

        this.bucketName = bucket.bucket;
        this.bucketId = bucket.id;
        this.bucketUrn = bucket.urn;
        this.registerOutputs({ bucketName: this.bucketName });
    }
}

```

```

```

<!-- /option -->

<!-- option: python -->
```python

```python
import pulumi
import pulumi_aws as aws

from migration import COMPONENT_TYPE, migrate_bucket_state

class VersionedBucket(pulumi.ComponentResource):
def __init__(
self, name: str, bucket_name: str, opts: pulumi.ResourceOptions | None = None
):
opts = pulumi.ResourceOptions.merge(
opts, pulumi.ResourceOptions(state_migrations=[migrate_bucket_state])
)
super().__init__(COMPONENT_TYPE, name, {}, opts)

bucket = aws.s3.Bucket(
name,
bucket=bucket_name,
force_destroy=True,
tags={"example": "s3-bucket-state-migration", "managed-by": "pulumi"},
versioning={"enabled": True},
opts=pulumi.ResourceOptions(parent=self),
)
self.bucket_name = bucket.bucket
self.bucket_id = bucket.id
self.bucket_urn = bucket.urn
self.register_outputs({"bucketName": self.bucket_name})

```

```

```

<!-- /option -->

<!-- option: go -->
```go

```go
package main

import (
	"github.com/pulumi/pulumi-aws/sdk/v7/go/aws/s3"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

type VersionedBucket struct {
	pulumi.ResourceState

	BucketName pulumi.StringOutput
	BucketID   pulumi.IDOutput
	BucketURN  pulumi.URNOutput
}

func NewVersionedBucket(
	ctx *pulumi.Context,
	name string,
	bucketName string,
	opts ...pulumi.ResourceOption,
) (*VersionedBucket, error) {
	opts = append(opts, pulumi.StateMigrations([]pulumi.StateMigration{
		migrateBucketState,
	}))

	component := &VersionedBucket{}
	if err := ctx.RegisterComponentResource(componentType, name, component, opts...); err != nil {
		return nil, err
	}
	childOpts := []pulumi.ResourceOption{pulumi.Parent(component)}
	tags := pulumi.StringMap{
		"example":    pulumi.String("s3-bucket-state-migration"),
		"managed-by": pulumi.String("pulumi"),
	}
	bucket, err := s3.NewBucket(ctx, name, &s3.BucketArgs{
		Bucket:       pulumi.String(bucketName),
		ForceDestroy: pulumi.Bool(true),
		Tags:         tags,
		Versioning: s3.BucketVersioningTypeArgs{
			Enabled: pulumi.Bool(true),
		},
	}, childOpts...)
	if err != nil {
		return nil, err
	}
	component.BucketName = bucket.Bucket
	component.BucketID = bucket.ID()
	component.BucketURN = bucket.URN()

	if err := ctx.RegisterResourceOutputs(component, pulumi.Map{
		"bucketName": component.BucketName,
	}); err != nil {
		return nil, err
	}
	return component, nil
}

```

```

```

<!-- /option -->

<!-- /chooser -->

The migration does not issue a provider delete for the omitted versioning entry. The new bucket resource takes responsibility for that configuration.

## How migrations run

1. During `pulumi preview` or `pulumi up`, Pulumi finds the component's prior state by URN or alias. If there is no prior state, it skips the callbacks.
1. Pulumi supplies the component itself first, followed by its descendants in snapshot order. This is the subtree defined by parent relationships, not every resource that depends on it.
1. Callbacks run in the order supplied. Each receives the state produced by earlier callbacks. A callback that returns no result leaves its input unchanged and allows later callbacks to run.
1. Pulumi validates the result and uses successor mappings to rewrite structural references and serialized resource references, including references from outside the subtree. Ordinary string values are not rewritten.
1. Pulumi diffs later resource registrations against the migrated state.

A preview evaluates the migration without saving it. An update persists the migration before proceeding with the affected resources. If a later operation fails, the migration may already be saved and callbacks must handle that state on the next run.

Migrations do not run during standalone refresh or destroy operations, including those using `--run-program`.

## Unit test a migration

Think of a migration as a `state[] → state[]` transformation, with successor mappings alongside the returned state. Unit testing it means supplying an array of prior state records, calling the callback directly, and comparing the returned array with the expected state. This can be tested without the Pulumi runtime, provider mocks, running stack, or cloud credentials.

These test sketches call the S3 migration directly with inline arrays of JSON-compatible state records. The expected array keeps the component and combines the bucket and versioning entries into one bucket entry with inline versioning. The sketches omit imports and the URN definitions; `...` comments stand for the remaining checkpoint fields, including parents, IDs, providers, and inputs. Include those fields in a runnable test.

<!-- chooser: language -->

<!-- option: typescript -->
```typescript

```typescript
test("migrates component state", () => {
const before = [{ urn: componentUrn, type: "example:storage:VersionedBucket" },
{ urn: oldBucketUrn, type: "aws:s3/bucketV2:BucketV2", /* ... */ },
{
urn: versioningUrn, type: "aws:s3/bucketVersioningV2:BucketVersioningV2",
outputs: { versioningConfiguration: { status: "Enabled", mfaDelete: "Disabled" } },
/* ... */
},
];
const expected = [{ urn: componentUrn, type: "example:storage:VersionedBucket" },
{
urn: bucketUrn, type: "aws:s3/bucket:Bucket",
outputs: { versioning: { enabled: true, mfaDelete: false } },
/* ... */
},
];

const result = migrateBucketState({ urn: before[0].urn, oldState: before });

assert.ok(result);
assert.deepEqual(result.newState, expected);
// Both prior resources have the inline bucket as their successor.
assert.deepEqual(result.successors, {
[oldBucketUrn]: bucketUrn,
[versioningUrn]: bucketUrn,
});
});

```

```

```

<!-- /option -->

<!-- option: python -->
```python

```python
def test_migrates_component_state():
    before = [
        {"urn": component_urn, "type": "example:storage:VersionedBucket"},
        {
            "urn": old_bucket_urn, "type": "aws:s3/bucketV2:BucketV2",
            # ...
        },
        {
            "urn": versioning_urn, "type": "aws:s3/bucketVersioningV2:BucketVersioningV2",
            "outputs": {"versioningConfiguration": {"status": "Enabled", "mfaDelete": "Disabled"}},
            # ...
        },
    ]
    expected = [
        {"urn": component_urn, "type": "example:storage:VersionedBucket"},
        {
            "urn": bucket_urn, "type": "aws:s3/bucket:Bucket",
            "outputs": {"versioning": {"enabled": True, "mfaDelete": False}},
            # ...
        },
    ]

    result = migrate_bucket_state(
        pulumi.StateMigrationArgs(urn=before[0]["urn"], old_state=before)
    )

    assert result is not None
    assert result.new_state == expected
    # Both prior resources have the inline bucket as their successor.
    assert result.successors == {
        old_bucket_urn: bucket_urn,
        versioning_urn: bucket_urn,
    }

```

```

```

<!-- /option -->

<!-- option: go -->
```go

```go
func TestMigratesComponentState(t *testing.T) {
before := []map[string]any{
{"urn": componentURN, "type": "example:storage:VersionedBucket"},
{"urn": oldBucketURN, "type": "aws:s3/bucketV2:BucketV2", /* ... */},
{
"urn": versioningURN, "type": "aws:s3/bucketVersioningV2:BucketVersioningV2",
"outputs": map[string]any{
"versioningConfiguration": map[string]any{"status": "Enabled", "mfaDelete": "Disabled"},
},
// ...
},
}
expected := []map[string]any{
{"urn": componentURN, "type": "example:storage:VersionedBucket"},
{
"urn": bucketURN, "type": "aws:s3/bucket:Bucket",
"outputs": map[string]any{
"versioning": map[string]any{"enabled": true, "mfaDelete": false},
},
// ...
},
}

result, err := migrateBucketState(context.Background(), &pulumi.StateMigrationArgs{
URN: pulumi.URN(before[0]["urn"].(string)), OldState: before,
})

if err != nil {
t.Fatal(err)
}
if result == nil || !reflect.DeepEqual(result.NewState, expected) {
t.Fatal("migration did not produce the expected state")
}
// Both prior resources have the inline bucket as their successor.
expectedSuccessors := map[string]string{
oldBucketURN: bucketURN,
versioningURN: bucketURN,
}
if !reflect.DeepEqual(result.Successors, expectedSuccessors) {
t.Fatal("migration did not map both prior resources to the inline bucket")
}
}

```

```

```

<!-- /option -->

<!-- /chooser -->

The successor assertions check that both the old bucket and the versioning resource map to the new inline bucket’s URN. You can pass the migrated state back into the callback and assert that it returns no changes. Use a preview and update to verify how the engine and provider apply the complete component upgrade.

For a runnable TypeScript example, see the [AWSX VPC state migration](https://github.com/pulumi/examples/tree/master/aws-ts-awsx-vpc-state-migration). It upgrades a VPC from legacy AWSX to modern AWSX and then to plain AWS resources, preserving the existing AWS resource IDs. It includes migration callbacks, tests, and instructions for running each version against the same stack.

## Restrictions

State-changing migrations require a full update. Pulumi rejects them in these situations:

- Targeted or excluded updates, including `--target`, `--exclude`, and `--replace`.
- Generating or applying an update plan. Apply the migration without a saved plan before returning to a plan-based workflow.
- Pending operations in the snapshot. Resolve them with `pulumi refresh` before migrating.
- Resources pending deletion in the subtree. Complete the unfinished deletion with the migration returning no changes before retrying.
- Persisted snippet references that would need to change. Update or remove those snippets first.

A callback that returns no result can remain attached when using update plans or targeted updates, the restrictions on state-changing results do not require removing an already-applied migration.
```
