Advanced State Management

7 minute read

State Evolution

As providers evolve, resource schemas change. Advanced state management ensures smooth migrations without data loss or user disruption.

State Schema Versions

Version Tracking

func (r *CacheResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
    resp.Schema = schema.Schema{
        // Current schema version
        Version: 2,
        
        Description: "Manages a cache instance",
        
        Attributes: map[string]schema.Attribute{
            "id": schema.StringAttribute{
                Computed: true,
            },
            "name": schema.StringAttribute{
                Required: true,
            },
            // v2: Renamed from instance_type
            "node_type": schema.StringAttribute{
                Required: true,
            },
            // v2: New attribute
            "engine_version": schema.StringAttribute{
                Optional: true,
                Computed: true,
            },
        },
    }
}

State Version History

Version 0 (initial):
├── id: string
├── name: string
└── instance_type: string

Version 1:
├── id: string
├── name: string
├── instance_type: string
└── engine: string (added)

Version 2 (current):
├── id: string
├── name: string
├── node_type: string (renamed from instance_type)
├── engine: string
└── engine_version: string (added)

State Upgraders

Implementing Upgraders

func (r *CacheResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader {
    return map[int64]resource.StateUpgrader{
        // Upgrade from version 0 to current
        0: {
            PriorSchema: &schema.Schema{
                Attributes: map[string]schema.Attribute{
                    "id": schema.StringAttribute{
                        Computed: true,
                    },
                    "name": schema.StringAttribute{
                        Required: true,
                    },
                    "instance_type": schema.StringAttribute{
                        Required: true,
                    },
                },
            },
            StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
                // Define old state structure
                type OldStateModel struct {
                    ID           types.String `tfsdk:"id"`
                    Name         types.String `tfsdk:"name"`
                    InstanceType types.String `tfsdk:"instance_type"`
                }
                
                var oldState OldStateModel
                resp.Diagnostics.Append(req.State.Get(ctx, &oldState)...)
                if resp.Diagnostics.HasError() {
                    return
                }
                
                // Migrate to new structure
                newState := CacheResourceModel{
                    ID:       oldState.ID,
                    Name:     oldState.Name,
                    NodeType: oldState.InstanceType, // Renamed field
                    Engine:   types.StringValue("redis"), // Default value
                }
                
                resp.Diagnostics.Append(resp.State.Set(ctx, newState)...)
            },
        },
        // Upgrade from version 1 to current
        1: {
            PriorSchema: &schema.Schema{
                Attributes: map[string]schema.Attribute{
                    "id": schema.StringAttribute{
                        Computed: true,
                    },
                    "name": schema.StringAttribute{
                        Required: true,
                    },
                    "instance_type": schema.StringAttribute{
                        Required: true,
                    },
                    "engine": schema.StringAttribute{
                        Required: true,
                    },
                },
            },
            StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
                type V1StateModel struct {
                    ID           types.String `tfsdk:"id"`
                    Name         types.String `tfsdk:"name"`
                    InstanceType types.String `tfsdk:"instance_type"`
                    Engine       types.String `tfsdk:"engine"`
                }
                
                var v1State V1StateModel
                resp.Diagnostics.Append(req.State.Get(ctx, &v1State)...)
                if resp.Diagnostics.HasError() {
                    return
                }
                
                // Migrate to v2
                newState := CacheResourceModel{
                    ID:       v1State.ID,
                    Name:     v1State.Name,
                    NodeType: v1State.InstanceType, // Renamed
                    Engine:   v1State.Engine,
                    // Fetch engine version from API
                    EngineVersion: types.StringUnknown(),
                }
                
                resp.Diagnostics.Append(resp.State.Set(ctx, newState)...)
            },
        },
    }
}

Testing State Upgrades

func TestCacheResource_StateUpgrade_V0ToV2(t *testing.T) {
    resource.UnitTest(t, resource.TestCase{
        Steps: []resource.TestStep{
            {
                // Step 1: Create with old provider version
                ExternalProviders: map[string]resource.ExternalProvider{
                    "cachecloud": {
                        VersionConstraint: "1.0.0", // Old version with v0 schema
                        Source:            "example/cachecloud",
                    },
                },
                Config: `
resource "cachecloud_cache" "test" {
  name          = "test-cache"
  instance_type = "cache.t3.micro"
}
`,
            },
            {
                // Step 2: Upgrade to new provider version
                ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
                Config: `
resource "cachecloud_cache" "test" {
  name      = "test-cache"
  node_type = "cache.t3.micro"  # Renamed attribute
  engine    = "redis"            # New default
}
`,
                // Verify state upgraded successfully
                Check: resource.ComposeTestCheckFunc(
                    resource.TestCheckResourceAttr("cachecloud_cache.test", "name", "test-cache"),
                    resource.TestCheckResourceAttr("cachecloud_cache.test", "node_type", "cache.t3.micro"),
                    resource.TestCheckResourceAttr("cachecloud_cache.test", "engine", "redis"),
                    resource.TestCheckResourceAttrSet("cachecloud_cache.test", "engine_version"),
                ),
            },
        },
    })
}

Complex State Migrations

Nested Attribute Changes

// Version 1: Flat structure
type V1NetworkConfig struct {
    VPCID    types.String `tfsdk:"vpc_id"`
    SubnetID types.String `tfsdk:"subnet_id"`
}

// Version 2: Nested structure
type V2NetworkConfig struct {
    VPC    *VPCConfig    `tfsdk:"vpc"`
    Subnet *SubnetConfig `tfsdk:"subnet"`
}

type VPCConfig struct {
    ID   types.String `tfsdk:"id"`
    CIDR types.String `tfsdk:"cidr"`
}

type SubnetConfig struct {
    ID types.String `tfsdk:"id"`
    AZ types.String `tfsdk:"availability_zone"`
}

// State upgrader
func upgradeV1ToV2(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
    var v1State struct {
        NetworkConfig *V1NetworkConfig `tfsdk:"network_config"`
    }
    
    resp.Diagnostics.Append(req.State.Get(ctx, &v1State)...)
    if resp.Diagnostics.HasError() {
        return
    }
    
    // Fetch additional data from API
    client := req.ProviderMeta.(*Client)
    vpcDetails, err := client.GetVPC(ctx, v1State.NetworkConfig.VPCID.ValueString())
    if err != nil {
        resp.Diagnostics.AddError("Migration Error", 
            fmt.Sprintf("Failed to fetch VPC details: %s", err))
        return
    }
    
    subnetDetails, err := client.GetSubnet(ctx, v1State.NetworkConfig.SubnetID.ValueString())
    if err != nil {
        resp.Diagnostics.AddError("Migration Error", 
            fmt.Sprintf("Failed to fetch subnet details: %s", err))
        return
    }
    
    // Build new state
    v2State := struct {
        NetworkConfig *V2NetworkConfig `tfsdk:"network_config"`
    }{
        NetworkConfig: &V2NetworkConfig{
            VPC: &VPCConfig{
                ID:   types.StringValue(vpcDetails.ID),
                CIDR: types.StringValue(vpcDetails.CIDRBlock),
            },
            Subnet: &SubnetConfig{
                ID: types.StringValue(subnetDetails.ID),
                AZ: types.StringValue(subnetDetails.AvailabilityZone),
            },
        },
    }
    
    resp.Diagnostics.Append(resp.State.Set(ctx, v2State)...)
}

List to Set Conversion

// V1: List attribute
"security_groups": schema.ListAttribute{
    ElementType: types.StringType,
}

// V2: Set attribute (order doesn't matter)
"security_groups": schema.SetAttribute{
    ElementType: types.StringType,
}

// Upgrader
func upgradeListToSet(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
    var v1State struct {
        SecurityGroups types.List `tfsdk:"security_groups"`
    }
    
    resp.Diagnostics.Append(req.State.Get(ctx, &v1State)...)
    if resp.Diagnostics.HasError() {
        return
    }
    
    // Extract list elements
    var sgList []types.String
    resp.Diagnostics.Append(v1State.SecurityGroups.ElementsAs(ctx, &sgList, false)...)
    if resp.Diagnostics.HasError() {
        return
    }
    
    // Convert to set
    sgValues := make([]attr.Value, len(sgList))
    for i, sg := range sgList {
        sgValues[i] = sg
    }
    
    sgSet, diags := types.SetValue(types.StringType, sgValues)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
    
    v2State := struct {
        SecurityGroups types.Set `tfsdk:"security_groups"`
    }{
        SecurityGroups: sgSet,
    }
    
    resp.Diagnostics.Append(resp.State.Set(ctx, v2State)...)
}

Attribute Path Management

Reading Nested Attributes

func (r *CacheResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state CacheResourceModel
    resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
    
    // Access nested attribute using path
    var vpcID string
    resp.Diagnostics.Append(req.State.GetAttribute(ctx, 
        path.Root("network_config").AtName("vpc").AtName("id"), 
        &vpcID)...)
    
    tflog.Debug(ctx, "VPC ID from state", map[string]interface{}{
        "vpc_id": vpcID,
    })
}

Setting Nested Attributes

func (r *CacheResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
    var plan, state CacheResourceModel
    resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
    resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
    
    // Update specific nested attribute
    if !plan.NetworkConfig.VPC.CIDR.Equal(state.NetworkConfig.VPC.CIDR) {
        resp.Diagnostics.Append(resp.State.SetAttribute(ctx,
            path.Root("network_config").AtName("vpc").AtName("cidr"),
            plan.NetworkConfig.VPC.CIDR)...)
    }
}

Validating Attribute Paths

type networkConfigValidator struct{}

func (v networkConfigValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) {
    // Get value at specific path
    var vpcID string
    diags := req.Config.GetAttribute(ctx, 
        req.Path.AtName("vpc").AtName("id"), 
        &vpcID)
    
    if diags.HasError() {
        resp.Diagnostics.Append(diags...)
        return
    }
    
    // Validate VPC ID format
    if !isValidVPCID(vpcID) {
        resp.Diagnostics.AddAttributeError(
            req.Path.AtName("vpc").AtName("id"),
            "Invalid VPC ID",
            fmt.Sprintf("VPC ID '%s' is not in valid format", vpcID),
        )
    }
}

State Recovery

Handling Corrupted State

func (r *CacheResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state CacheResourceModel
    resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
    if resp.Diagnostics.HasError() {
        return
    }
    
    // Attempt to fetch current state from API
    cache, err := r.client.GetCache(ctx, state.ID.ValueString())
    if err != nil {
        if isNotFoundError(err) {
            // Resource deleted outside Terraform
            tflog.Warn(ctx, "Resource not found, removing from state",
                map[string]interface{}{"id": state.ID.ValueString()})
            resp.State.RemoveResource(ctx)
            return
        }
        
        resp.Diagnostics.AddError("API Error",
            fmt.Sprintf("Failed to read cache: %s", err))
        return
    }
    
    // Rebuild state from API response
    newState := CacheResourceModel{
        ID:       types.StringValue(cache.ID),
        Name:     types.StringValue(cache.Name),
        NodeType: types.StringValue(cache.NodeType),
    }
    
    // Preserve computed values if API doesn't return them
    if cache.EngineVersion == "" {
        newState.EngineVersion = state.EngineVersion
    } else {
        newState.EngineVersion = types.StringValue(cache.EngineVersion)
    }
    
    resp.Diagnostics.Append(resp.State.Set(ctx, &newState)...)
}

Import with State Reconstruction

func (r *CacheResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
    // Import using ID
    id := req.ID
    
    // Fetch full resource details
    cache, err := r.client.GetCache(ctx, id)
    if err != nil {
        resp.Diagnostics.AddError("Import Error",
            fmt.Sprintf("Failed to import cache: %s", err))
        return
    }
    
    // Reconstruct complete state
    state := CacheResourceModel{
        ID:            types.StringValue(cache.ID),
        Name:          types.StringValue(cache.Name),
        NodeType:      types.StringValue(cache.NodeType),
        Engine:        types.StringValue(cache.Engine),
        EngineVersion: types.StringValue(cache.EngineVersion),
    }
    
    // Fetch and populate nested attributes
    if cache.NetworkConfig != nil {
        state.NetworkConfig = &NetworkConfigModel{
            VPCID: types.StringValue(cache.NetworkConfig.VPCID),
        }
        
        // Build subnet list
        subnetValues := make([]attr.Value, len(cache.NetworkConfig.Subnets))
        for i, subnet := range cache.NetworkConfig.Subnets {
            subnetValues[i] = types.StringValue(subnet)
        }
        subnetList, diags := types.ListValue(types.StringType, subnetValues)
        resp.Diagnostics.Append(diags...)
        state.NetworkConfig.SubnetIDs = subnetList
    }
    
    resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
}

Handling Computed Attributes

Conditional Computation

schema.Int64Attribute{
    Description: "Current connection count",
    Computed:    true,
    PlanModifiers: []planmodifier.Int64{
        int64planmodifier.UseStateForUnknown(), // Keep state value if plan unknown
    },
}

Custom Plan Modifier

type conditionalComputeModifier struct{}

func (m conditionalComputeModifier) Description(ctx context.Context) string {
    return "Conditionally recomputes based on configuration changes"
}

func (m conditionalComputeModifier) MarkdownDescription(ctx context.Context) string {
    return "Conditionally recomputes based on configuration changes"
}

func (m conditionalComputeModifier) PlanModifyInt64(ctx context.Context, req planmodifier.Int64Request, resp *planmodifier.Int64Response) {
    // If configuration hasn't changed, use state value
    var planConfig, stateConfig CacheResourceModel
    req.Plan.Get(ctx, &planConfig)
    req.State.Get(ctx, &stateConfig)
    
    if planConfig.NodeType.Equal(stateConfig.NodeType) && 
       planConfig.Engine.Equal(stateConfig.Engine) {
        // No significant changes, keep state value
        resp.PlanValue = req.StateValue
        return
    }
    
    // Configuration changed, mark as unknown (will be computed)
    resp.PlanValue = types.Int64Unknown()
}

Best Practices

1. Version Every Schema Change

// ✅ Good: Increment version
resp.Schema = schema.Schema{
    Version: 3, // Incremented
}

// ❌ Bad: No version change
resp.Schema = schema.Schema{
    Version: 2, // Not incremented despite changes
}

2. Provide Clear Migration Path

// ✅ Good: Handle each version
func (r *Resource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader {
    return map[int64]resource.StateUpgrader{
        0: {/* v0 to v3 */},
        1: {/* v1 to v3 */},
        2: {/* v2 to v3 */},
    }
}

// ❌ Bad: Skip versions
func (r *Resource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader {
    return map[int64]resource.StateUpgrader{
        0: {/* v0 to v3, but v1 and v2 unsupported */},
    }
}

3. Test State Upgrades

// ✅ Good: Test all upgrade paths
func TestStateUpgrade_V0ToV1(t *testing.T) {}
func TestStateUpgrade_V1ToV2(t *testing.T) {}
func TestStateUpgrade_V2ToV3(t *testing.T) {}
func TestStateUpgrade_V0ToV3(t *testing.T) {} // End-to-end

// ❌ Bad: No upgrade tests

4. Document Breaking Changes

# CHANGELOG.md

## [2.0.0] - 2026-07-01

### BREAKING CHANGES
- `instance_type` attribute renamed to `node_type`
- State will be automatically upgraded on first apply
- Backup your state before upgrading

### Migration
```hcl
# Old (v1.x)
resource "cachecloud_cache" "example" {
  instance_type = "cache.t3.micro"
}

# New (v2.x)
resource "cachecloud_cache" "example" {
  node_type = "cache.t3.micro"  # Renamed
}

```

Key Takeaways

  • ✅ Version schema for every change
  • ✅ Implement state upgraders for migrations
  • ✅ Test all upgrade paths thoroughly
  • ✅ Use attribute paths for nested access
  • ✅ Handle corrupted/missing state gracefully
  • ✅ Provide clear migration documentation
  • ✅ Preserve user data during upgrades
  • ✅ Support importing existing resources

What’s Next?

Learn to build custom validators for complex business logic.

Next: Custom Validation →


Previous: ← Performance Optimization
Next: Custom Validation →

Last updated: July 1, 2026

Updated: