Schema Upgrades

7 minute read

Why Schema Upgrades Matter

Provider schemas evolve over time. When breaking changes occur, existing state files need migration to work with new provider versions.

Without upgrades:

$ terraform init -upgrade
$ terraform plan

Error: Invalid state for mycloud_database.db
The resource state is incompatible with the current schema.

With upgrades:

$ terraform init -upgrade
$ terraform plan

mycloud_database.db: Refreshing state...
  Upgrading state from version 0 to version 1...
  State migration successful.

No changes. Your infrastructure matches the configuration.

When to Upgrade Schema Version

Breaking Changes Require Version Bump

Increment schema version when:

  • Renaming attributes
  • Changing attribute types
  • Removing attributes
  • Changing nested object structure
  • Changing attribute semantics (same type, different meaning)

Example:

// Version 0: Storage as integer
resp.Schema = schema.Schema{
    Version: 0,
    Attributes: map[string]schema.Attribute{
        "storage": schema.Int64Attribute{
            Required: true,
        },
    },
}

// Version 1: Storage as string with unit (BREAKING)
resp.Schema = schema.Schema{
    Version: 1,  // ← Incremented
    Attributes: map[string]schema.Attribute{
        "storage": schema.StringAttribute{  // Changed type!
            Required: true,
        },
    },
}

Non-Breaking Changes Don’t Need Version Bump

Don’t increment for:

  • Adding new optional attributes
  • Adding new computed attributes
  • Changing descriptions
  • Updating validation rules
  • Fixing bugs that don’t change structure
// Version 1
resp.Schema = schema.Schema{
    Version: 1,  // No change
    Attributes: map[string]schema.Attribute{
        "storage": schema.StringAttribute{
            Required: true,
        },
        // Adding optional attribute - not breaking
        "backup_enabled": schema.BoolAttribute{
            Optional: true,
        },
    },
}

Implementing State Upgrades

Basic Upgrade Structure

import (
    "context"
    "encoding/json"
    
    "github.com/hashicorp/terraform-plugin-framework/resource"
    "github.com/hashicorp/terraform-plugin-framework/resource/schema"
)

var _ resource.ResourceWithUpgradeState = &DatabaseResource{}

func (r *DatabaseResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader {
    return map[int64]resource.StateUpgrader{
        // Upgrade from version 0 to version 1
        0: {
            PriorSchema:   &DatabaseResourceSchemaV0,
            StateUpgrader: upgradeDatabaseStateV0toV1,
        },
        // Upgrade from version 1 to version 2
        1: {
            PriorSchema:   &DatabaseResourceSchemaV1,
            StateUpgrader: upgradeDatabaseStateV1toV2,
        },
    }
}

func (r *DatabaseResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
    resp.Schema = schema.Schema{
        Version: 2,  // Current version
        
        Attributes: map[string]schema.Attribute{
            "id": schema.StringAttribute{
                Computed: true,
            },
            "storage": schema.StringAttribute{
                Required: true,
            },
        },
    }
}

Complete Upgrade Example

Version 0 → Version 1: Change storage from int to string

// Define V0 schema (for migration reference)
var DatabaseResourceSchemaV0 = schema.Schema{
    Version: 0,
    Attributes: map[string]schema.Attribute{
        "id": schema.StringAttribute{
            Computed: true,
        },
        "storage": schema.Int64Attribute{  // V0: Integer
            Required: true,
        },
    },
}

// Upgrade function
func upgradeDatabaseStateV0toV1(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
    type ModelV0 struct {
        ID      string `tfsdk:"id"`
        Storage int64  `tfsdk:"storage"`
    }
    
    var stateV0 ModelV0
    
    // Unmarshal old state
    err := json.Unmarshal(req.RawState.JSON, &stateV0)
    if err != nil {
        resp.Diagnostics.AddError(
            "State Upgrade Failed",
            fmt.Sprintf("Could not unmarshal v0 state: %s", err.Error()),
        )
        return
    }
    
    tflog.Info(ctx, "Upgrading database state from v0 to v1", map[string]any{
        "id":         stateV0.ID,
        "storage_v0": stateV0.Storage,
    })
    
    // Convert to new format
    type ModelV1 struct {
        ID      string `tfsdk:"id"`
        Storage string `tfsdk:"storage"`  // V1: String
    }
    
    stateV1 := ModelV1{
        ID:      stateV0.ID,
        Storage: fmt.Sprintf("%dGB", stateV0.Storage),  // 100 → "100GB"
    }
    
    // Set upgraded state
    resp.State.SetAttribute(ctx, path.Root("id"), stateV1.ID)
    resp.State.SetAttribute(ctx, path.Root("storage"), stateV1.Storage)
    
    tflog.Info(ctx, "State upgraded successfully", map[string]any{
        "id":         stateV1.ID,
        "storage_v1": stateV1.Storage,
    })
}

Version 1 → Version 2: Rename Attribute

Change storage to storage_size:

var DatabaseResourceSchemaV1 = schema.Schema{
    Version: 1,
    Attributes: map[string]schema.Attribute{
        "id": schema.StringAttribute{
            Computed: true,
        },
        "storage": schema.StringAttribute{  // V1: storage
            Required: true,
        },
    },
}

func upgradeDatabaseStateV1toV2(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
    type ModelV1 struct {
        ID      string `tfsdk:"id"`
        Storage string `tfsdk:"storage"`  // Old name
    }
    
    var stateV1 ModelV1
    
    err := json.Unmarshal(req.RawState.JSON, &stateV1)
    if err != nil {
        resp.Diagnostics.AddError("State Upgrade Failed", err.Error())
        return
    }
    
    tflog.Info(ctx, "Upgrading database state from v1 to v2", map[string]any{
        "id": stateV1.ID,
    })
    
    // Rename attribute
    type ModelV2 struct {
        ID          string `tfsdk:"id"`
        StorageSize string `tfsdk:"storage_size"`  // New name
    }
    
    stateV2 := ModelV2{
        ID:          stateV1.ID,
        StorageSize: stateV1.Storage,  // Copy value
    }
    
    resp.State.SetAttribute(ctx, path.Root("id"), stateV2.ID)
    resp.State.SetAttribute(ctx, path.Root("storage_size"), stateV2.StorageSize)
    
    tflog.Info(ctx, "State upgraded: storage renamed to storage_size", map[string]any{
        "id":           stateV2.ID,
        "storage_size": stateV2.StorageSize,
    })
}

Complex Migration Scenarios

Migrating Nested Objects

V0: Flat structure

"backup_enabled":           types.Bool
"backup_retention_days":    types.Int64
"backup_preferred_window":  types.String

V1: Nested object

"backup_config": types.Object{
    "enabled":          types.Bool,
    "retention_days":   types.Int64,
    "preferred_window": types.String,
}

Migration:

func upgradeDatabaseStateV0toV1(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
    type ModelV0 struct {
        ID                     string `tfsdk:"id"`
        BackupEnabled          bool   `tfsdk:"backup_enabled"`
        BackupRetentionDays    int64  `tfsdk:"backup_retention_days"`
        BackupPreferredWindow  string `tfsdk:"backup_preferred_window"`
    }
    
    var stateV0 ModelV0
    json.Unmarshal(req.RawState.JSON, &stateV0)
    
    // Create nested object
    backupConfig := map[string]attr.Value{
        "enabled":          types.BoolValue(stateV0.BackupEnabled),
        "retention_days":   types.Int64Value(stateV0.BackupRetentionDays),
        "preferred_window": types.StringValue(stateV0.BackupPreferredWindow),
    }
    
    backupConfigObj, _ := types.ObjectValue(
        map[string]attr.Type{
            "enabled":          types.BoolType,
            "retention_days":   types.Int64Type,
            "preferred_window": types.StringType,
        },
        backupConfig,
    )
    
    resp.State.SetAttribute(ctx, path.Root("id"), stateV0.ID)
    resp.State.SetAttribute(ctx, path.Root("backup_config"), backupConfigObj)
    
    tflog.Info(ctx, "Migrated flat backup fields to nested object")
}

Splitting Attributes

V0: Single endpoint string

"endpoint": "prod-db.example.com:5432"

V1: Separate host and port

"host": "prod-db.example.com"
"port": 5432

Migration:

func upgradeDatabaseStateV0toV1(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
    type ModelV0 struct {
        ID       string `tfsdk:"id"`
        Endpoint string `tfsdk:"endpoint"`
    }
    
    var stateV0 ModelV0
    json.Unmarshal(req.RawState.JSON, &stateV0)
    
    // Parse endpoint
    parts := strings.Split(stateV0.Endpoint, ":")
    host := parts[0]
    port := 5432  // Default
    
    if len(parts) == 2 {
        port, _ = strconv.Atoi(parts[1])
    }
    
    tflog.Info(ctx, "Split endpoint into host and port", map[string]any{
        "endpoint": stateV0.Endpoint,
        "host":     host,
        "port":     port,
    })
    
    resp.State.SetAttribute(ctx, path.Root("id"), stateV0.ID)
    resp.State.SetAttribute(ctx, path.Root("host"), host)
    resp.State.SetAttribute(ctx, path.Root("port"), int64(port))
}

Removing Deprecated Attributes

V0: Has deprecated attribute

"legacy_mode": types.Bool

V1: Attribute removed

Migration:

func upgradeDatabaseStateV0toV1(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
    type ModelV0 struct {
        ID         string `tfsdk:"id"`
        Name       string `tfsdk:"name"`
        LegacyMode bool   `tfsdk:"legacy_mode"`  // Deprecated
    }
    
    var stateV0 ModelV0
    json.Unmarshal(req.RawState.JSON, &stateV0)
    
    if stateV0.LegacyMode {
        tflog.Warn(ctx, "legacy_mode was enabled but is no longer supported")
    }
    
    // Copy only non-deprecated attributes
    resp.State.SetAttribute(ctx, path.Root("id"), stateV0.ID)
    resp.State.SetAttribute(ctx, path.Root("name"), stateV0.Name)
    // Don't set legacy_mode - it's gone
    
    tflog.Info(ctx, "Removed deprecated legacy_mode attribute")
}

Testing State Upgrades

Automated Testing

func TestDatabaseResource_UpgradeState_V0toV1(t *testing.T) {
    // Simulate V0 state
    rawStateV0 := `{
        "id": "db-12345",
        "storage": 100
    }`
    
    req := resource.UpgradeStateRequest{
        RawState: &tfjson.State{
            JSON: []byte(rawStateV0),
        },
    }
    
    resp := &resource.UpgradeStateResponse{
        State: tfsdk.State{
            Schema: DatabaseResourceSchemaV1,
        },
    }
    
    // Run upgrade
    upgradeDatabaseStateV0toV1(context.Background(), req, resp)
    
    // Verify
    var storage string
    resp.State.GetAttribute(context.Background(), path.Root("storage"), &storage)
    
    if storage != "100GB" {
        t.Errorf("Expected storage '100GB', got '%s'", storage)
    }
}

Integration Testing

func TestDatabaseResource_StateUpgrade_Integration(t *testing.T) {
    resource.Test(t, resource.TestCase{
        Steps: []resource.TestStep{
            {
                // Use old provider version with V0 schema
                ExternalProviders: map[string]resource.ExternalProvider{
                    "mycloud": {
                        Source:            "yourorg/mycloud",
                        VersionConstraint: "1.0.0",  // Has V0 schema
                    },
                },
                Config: `
                    resource "mycloud_database" "test" {
                      storage = 100
                    }
                `,
            },
            {
                // Upgrade to new provider version with V1 schema
                ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
                Config: `
                    resource "mycloud_database" "test" {
                      storage = "100GB"
                    }
                `,
                PlanOnly: true,  // Should show no changes after migration
            },
        },
    })
}

User Experience

Automatic Upgrade on Init

$ terraform init -upgrade

Upgrading provider yourorg/mycloud from 1.0.0 to 2.0.0...

$ terraform plan

mycloud_database.db: Refreshing state... [id=db-12345]
  Migrating state from schema version 0 to 1...
  State migration successful.

No changes. Your infrastructure matches the configuration.

Configuration Updates Needed

When schema changes, users may need to update configuration:

$ terraform plan

Error: Invalid configuration for mycloud_database.db

The attribute "storage" has changed from integer to string.
Update your configuration:

  Before:
    storage = 100

  After:
    storage = "100GB"

Best Practices

1. Always Provide Upgrade Path

// ✅ Good: Define upgrade for every version
func (r *DatabaseResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader {
    return map[int64]resource.StateUpgrader{
        0: {
            PriorSchema:   &DatabaseResourceSchemaV0,
            StateUpgrader: upgradeDatabaseStateV0toV1,
        },
        1: {
            PriorSchema:   &DatabaseResourceSchemaV1,
            StateUpgrader: upgradeDatabaseStateV1toV2,
        },
    }
}

// ❌ Bad: No upgrade path
// Users with v0 state can't upgrade!

2. Preserve Prior Schemas

// ✅ Good: Keep old schema definitions
var DatabaseResourceSchemaV0 = schema.Schema{
    Version: 0,
    Attributes: map[string]schema.Attribute{
        // V0 schema for migration reference
    },
}

var DatabaseResourceSchemaV1 = schema.Schema{
    Version: 1,
    Attributes: map[string]schema.Attribute{
        // V1 schema for migration reference
    },
}

3. Log Migrations

// ✅ Good: Log for debugging
tflog.Info(ctx, "Upgrading state from v0 to v1", map[string]any{
    "id":      stateV0.ID,
    "changes": "storage: int→string",
})

tflog.Info(ctx, "State upgrade complete", map[string]any{
    "id":           stateV1.ID,
    "new_storage":  stateV1.Storage,
})

4. Handle Edge Cases

// ✅ Good: Validate and handle edge cases
if stateV0.Storage <= 0 {
    resp.Diagnostics.AddWarning(
        "Invalid Storage",
        fmt.Sprintf("Storage was %d, defaulting to 100GB", stateV0.Storage),
    )
    stateV1.Storage = "100GB"
} else {
    stateV1.Storage = fmt.Sprintf("%dGB", stateV0.Storage)
}

5. Test Upgrades

// ✅ Good: Test every upgrade path
func TestUpgradeV0toV1(t *testing.T) { /* ... */ }
func TestUpgradeV1toV2(t *testing.T) { /* ... */ }
func TestUpgradeV0toV2_Chain(t *testing.T) { /* ... */ }  // Multi-hop

6. Document Breaking Changes

CHANGELOG.md:

## 2.0.0 (2026-07-01)

BREAKING CHANGES:

* resource/mycloud_database: The `storage` attribute has changed from integer
  to string with unit suffix (e.g., "100GB"). State will be automatically
  migrated. Update your configuration:
  
  ```hcl
  # Before
  storage = 100
  
  # After
  storage = "100GB"
  • resource/mycloud_database: The backup_* attributes have been combined into a backup_config block. State will be automatically migrated. ```

Deprecation Strategy

Phase 1: Deprecate

"legacy_option": schema.BoolAttribute{
    Optional:            true,
    DeprecationMessage:  "This attribute is deprecated and will be removed in v3.0. Use new_option instead.",
},

Phase 2: Still Accept (Multiple Versions)

Provider version 2.x accepts both old and new attributes.

Phase 3: Remove and Migrate

Provider version 3.0 removes deprecated attribute with migration.

Key Takeaways

  • ✅ Increment schema version for breaking changes
  • ✅ Implement UpgradeState for all version transitions
  • ✅ Keep prior schema definitions for migration reference
  • ✅ Test upgrade paths thoroughly
  • ✅ Log state migrations for debugging
  • ✅ Handle edge cases in migration logic
  • ✅ Document breaking changes in CHANGELOG
  • ✅ Use deprecation warnings before removal
  • ✅ Migrations run automatically on terraform init -upgrade
  • ✅ Users may need to update configuration after upgrade

What’s Next?

Learn advanced state manipulation techniques.

Next: State Manipulation →


Previous: ← Drift Detection

Last updated: July 1, 2026

Updated: