Schema Upgrades
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 abackup_configblock. 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.
Previous: ← Drift Detection
Last updated: July 1, 2026