State Refresh

9 minute read

What is State Refresh?

State refresh updates Terraform’s view of resources to match actual infrastructure state by calling the Read operation.

When refresh happens:

  • Before every terraform plan
  • Before every terraform apply
  • During terraform refresh command
  • After import
  • After create, update operations
┌──────────────────────────────────────────┐
│          State Refresh Flow               │
├──────────────────────────────────────────┤
│                                           │
│  1. Read state file                      │
│     { id: "db-12345", storage_gb: 100 }  │
│                                           │
│  2. Call Read() for each resource        │
│     GET /api/databases/db-12345          │
│                                           │
│  3. Update state with API response       │
│     { id: "db-12345", storage_gb: 200 }  │
│                                           │
│  4. Continue with plan/apply             │
│                                           │
└──────────────────────────────────────────┘

Read Operation Fundamentals

Basic Read Implementation

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    // 1. Get current state
    var state DatabaseResourceModel
    diags := req.State.Get(ctx, &state)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
    
    id := state.ID.ValueString()
    
    tflog.Debug(ctx, "Reading database", map[string]any{
        "id": id,
    })
    
    // 2. Fetch from API
    db, err := r.client.GetDatabase(ctx, id)
    
    if err != nil {
        if client.IsNotFoundError(err) {
            // Resource deleted outside Terraform
            tflog.Info(ctx, "Database not found, removing from state", map[string]any{
                "id": id,
            })
            resp.State.RemoveResource(ctx)
            return
        }
        
        resp.Diagnostics.AddError(
            "Read Failed",
            fmt.Sprintf("Could not read database %s: %s", id, err.Error()),
        )
        return
    }
    
    // 3. Update state with current values
    state.Name = types.StringValue(db.Name)
    state.Engine = types.StringValue(db.Engine)
    state.EngineVersion = types.StringValue(db.EngineVersion)
    state.StorageGB = types.Int64Value(int64(db.StorageGB))
    state.Port = types.Int64Value(int64(db.Port))
    state.Endpoint = types.StringValue(db.Endpoint)
    state.Status = types.StringValue(db.Status)
    
    // Convert tags
    if len(db.Tags) > 0 {
        tagsMap := make(map[string]attr.Value)
        for k, v := range db.Tags {
            tagsMap[k] = types.StringValue(v)
        }
        tagValue, _ := types.MapValue(types.StringType, tagsMap)
        state.Tags = tagValue
    } else {
        state.Tags = types.MapNull(types.StringType)
    }
    
    // 4. Save updated state
    resp.State.Set(ctx, state)
    
    tflog.Debug(ctx, "Successfully read database", map[string]any{
        "id":         db.ID,
        "status":     db.Status,
        "storage_gb": db.StorageGB,
    })
}

Handling Deleted Resources

Resource Not Found

When a resource is deleted outside Terraform, remove it from state:

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state DatabaseResourceModel
    req.State.Get(ctx, &state)
    
    db, err := r.client.GetDatabase(ctx, state.ID.ValueString())
    
    if err != nil {
        if client.IsNotFoundError(err) {
            // ✅ Remove from state
            resp.State.RemoveResource(ctx)
            
            tflog.Warn(ctx, "Database not found, removed from state", map[string]any{
                "id": state.ID.ValueString(),
            })
            
            return
        }
        
        // Other errors
        resp.Diagnostics.AddError("Read Failed", err.Error())
        return
    }
    
    // Update state normally...
}

User Experience:

$ terraform plan

mycloud_database.db: Refreshing state... [id=db-12345]

Terraform detected the following changes made outside of Terraform:

  # mycloud_database.db has been deleted
  - resource "mycloud_database" "db" {
      - id        = "db-12345" -> null
      - name      = "test-db" -> null
      - storage_gb = 100 -> null
    }

Terraform will perform the following actions:

  # mycloud_database.db will be created
  + resource "mycloud_database" "db" {
      + id         = (known after apply)
      + name       = "test-db"
      + storage_gb = 100
    }

Never Error on Not Found

// ❌ Bad: Error when not found
if client.IsNotFoundError(err) {
    resp.Diagnostics.AddError(
        "Database Not Found",
        "Database was deleted",
    )
    return
}

// ✅ Good: Remove from state
if client.IsNotFoundError(err) {
    resp.State.RemoveResource(ctx)
    return
}

Why? Terraform needs to know the resource is gone to plan recreation.

Refreshing All Attributes

Update Every Attribute

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state DatabaseResourceModel
    req.State.Get(ctx, &state)
    
    db, err := r.client.GetDatabase(ctx, state.ID.ValueString())
    if err != nil {
        if client.IsNotFoundError(err) {
            resp.State.RemoveResource(ctx)
            return
        }
        resp.Diagnostics.AddError("Read Failed", err.Error())
        return
    }
    
    // ✅ Update ALL attributes from API
    state.ID = types.StringValue(db.ID)                         // Even if ID doesn't change
    state.Name = types.StringValue(db.Name)                     // May have changed
    state.Engine = types.StringValue(db.Engine)                 // Usually immutable
    state.EngineVersion = types.StringValue(db.EngineVersion)   // May have upgraded
    state.StorageGB = types.Int64Value(int64(db.StorageGB))    // May have scaled
    state.Port = types.Int64Value(int64(db.Port))              // May have changed
    state.Endpoint = types.StringValue(db.Endpoint)             // May have changed
    state.Status = types.StringValue(db.Status)                 // Changes frequently
    state.MultiAZ = types.BoolValue(db.MultiAZ)                // May have toggled
    
    // Update complex types
    state.Tags = convertTags(db.Tags)
    state.SecurityGroups = convertList(db.SecurityGroupIDs)
    
    resp.State.Set(ctx, state)
}

Don’t Skip “Immutable” Fields

// ❌ Bad: Assuming fields never change
func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    // ...
    
    // Don't skip engine - what if API changed it?
    // state.Engine = ... (skipped)
    
    state.StorageGB = types.Int64Value(int64(db.StorageGB))
    
    resp.State.Set(ctx, state)
}

// ✅ Good: Update everything
state.Engine = types.StringValue(db.Engine)
state.EngineVersion = types.StringValue(db.EngineVersion)
state.StorageGB = types.Int64Value(int64(db.StorageGB))

Handling Null and Empty Values

String Attributes

// Handle optional strings that may be empty
if db.Description == "" {
    state.Description = types.StringNull()
} else {
    state.Description = types.StringValue(db.Description)
}

// Handle pointer fields
if db.MasterUsername != nil {
    state.MasterUsername = types.StringValue(*db.MasterUsername)
} else {
    state.MasterUsername = types.StringNull()
}

Maps

// Handle tags that may be empty
if len(db.Tags) > 0 {
    tagsMap := make(map[string]attr.Value)
    for k, v := range db.Tags {
        tagsMap[k] = types.StringValue(v)
    }
    mapValue, diags := types.MapValue(types.StringType, tagsMap)
    resp.Diagnostics.Append(diags...)
    state.Tags = mapValue
} else {
    state.Tags = types.MapNull(types.StringType)
}

Lists

// Handle lists that may be empty
if len(db.SecurityGroupIDs) > 0 {
    elements := make([]attr.Value, len(db.SecurityGroupIDs))
    for i, id := range db.SecurityGroupIDs {
        elements[i] = types.StringValue(id)
    }
    listValue, diags := types.ListValue(types.StringType, elements)
    resp.Diagnostics.Append(diags...)
    state.SecurityGroupIDs = listValue
} else {
    state.SecurityGroupIDs = types.ListNull(types.StringType)
}

Refreshing Nested Objects

Single Nested Object

type DatabaseResourceModel struct {
    ID             types.String `tfsdk:"id"`
    BackupConfig   types.Object `tfsdk:"backup_config"`
}

type BackupConfigModel struct {
    Enabled           types.Bool   `tfsdk:"enabled"`
    RetentionDays     types.Int64  `tfsdk:"retention_days"`
    PreferredWindow   types.String `tfsdk:"preferred_window"`
}

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state DatabaseResourceModel
    req.State.Get(ctx, &state)
    
    db, err := r.client.GetDatabase(ctx, state.ID.ValueString())
    if err != nil {
        // Handle error...
        return
    }
    
    // Update nested object
    if db.BackupConfig != nil {
        backupConfigAttrs := map[string]attr.Value{
            "enabled":           types.BoolValue(db.BackupConfig.Enabled),
            "retention_days":    types.Int64Value(int64(db.BackupConfig.RetentionDays)),
            "preferred_window":  types.StringValue(db.BackupConfig.PreferredWindow),
        }
        
        objectValue, diags := types.ObjectValue(
            map[string]attr.Type{
                "enabled":          types.BoolType,
                "retention_days":   types.Int64Type,
                "preferred_window": types.StringType,
            },
            backupConfigAttrs,
        )
        resp.Diagnostics.Append(diags...)
        state.BackupConfig = objectValue
    } else {
        state.BackupConfig = types.ObjectNull(
            map[string]attr.Type{
                "enabled":          types.BoolType,
                "retention_days":   types.Int64Type,
                "preferred_window": types.StringType,
            },
        )
    }
    
    resp.State.Set(ctx, state)
}

List of Objects

type LoadBalancerResourceModel struct {
    ID        types.String `tfsdk:"id"`
    Listeners types.List   `tfsdk:"listeners"`
}

func (r *LoadBalancerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state LoadBalancerResourceModel
    req.State.Get(ctx, &state)
    
    lb, err := r.client.GetLoadBalancer(ctx, state.ID.ValueString())
    if err != nil {
        // Handle error...
        return
    }
    
    // Update list of listener objects
    listenerObjectType := types.ObjectType{
        AttrTypes: map[string]attr.Type{
            "port":            types.Int64Type,
            "protocol":        types.StringType,
            "certificate_arn": types.StringType,
        },
    }
    
    if len(lb.Listeners) > 0 {
        listenerObjects := make([]attr.Value, len(lb.Listeners))
        
        for i, listener := range lb.Listeners {
            certARN := types.StringNull()
            if listener.CertificateARN != "" {
                certARN = types.StringValue(listener.CertificateARN)
            }
            
            objValue, diags := types.ObjectValue(
                listenerObjectType.AttrTypes,
                map[string]attr.Value{
                    "port":            types.Int64Value(int64(listener.Port)),
                    "protocol":        types.StringValue(listener.Protocol),
                    "certificate_arn": certARN,
                },
            )
            resp.Diagnostics.Append(diags...)
            listenerObjects[i] = objValue
        }
        
        listValue, diags := types.ListValue(listenerObjectType, listenerObjects)
        resp.Diagnostics.Append(diags...)
        state.Listeners = listValue
    } else {
        state.Listeners = types.ListNull(listenerObjectType)
    }
    
    resp.State.Set(ctx, state)
}

API Error Handling

Transient Errors

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state DatabaseResourceModel
    req.State.Get(ctx, &state)
    
    db, err := r.client.GetDatabase(ctx, state.ID.ValueString())
    
    if err != nil {
        // Not found - remove from state
        if client.IsNotFoundError(err) {
            resp.State.RemoveResource(ctx)
            return
        }
        
        // Throttling - return error
        if client.IsThrottlingError(err) {
            resp.Diagnostics.AddError(
                "API Rate Limited",
                "Too many requests. Wait a moment and try again.",
            )
            return
        }
        
        // Permission denied
        if client.IsUnauthorizedError(err) {
            resp.Diagnostics.AddError(
                "Permission Denied",
                fmt.Sprintf("Insufficient permissions to read database %s", state.ID.ValueString()),
            )
            return
        }
        
        // Network error - generic message
        resp.Diagnostics.AddError(
            "Read Failed",
            fmt.Sprintf("Could not read database: %s", err.Error()),
        )
        return
    }
    
    // Update state normally...
}

Handling Partial Responses

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state DatabaseResourceModel
    req.State.Get(ctx, &state)
    
    db, err := r.client.GetDatabase(ctx, state.ID.ValueString())
    if err != nil {
        // Handle errors...
        return
    }
    
    // Some fields may be nil during provisioning
    state.ID = types.StringValue(db.ID)
    state.Status = types.StringValue(db.Status)
    
    // Endpoint not available until status = "available"
    if db.Endpoint != "" {
        state.Endpoint = types.StringValue(db.Endpoint)
    } else {
        state.Endpoint = types.StringNull()
    }
    
    // Port may not be set during creation
    if db.Port > 0 {
        state.Port = types.Int64Value(int64(db.Port))
    } else {
        // Keep previous value or set null
        if state.Port.IsNull() {
            state.Port = types.Int64Null()
        }
        // else: keep existing value
    }
    
    resp.State.Set(ctx, state)
}

Refresh Performance

Minimize API Calls

// ✅ Good: Single API call fetches all data
func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state DatabaseResourceModel
    req.State.Get(ctx, &state)
    
    // One API call gets everything
    db, err := r.client.GetDatabase(ctx, state.ID.ValueString())
    
    // Update all attributes from single response
    state.Name = types.StringValue(db.Name)
    state.Tags = convertTags(db.Tags)
    state.Status = types.StringValue(db.Status)
    
    resp.State.Set(ctx, state)
}

// ❌ Bad: Multiple API calls
func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state DatabaseResourceModel
    req.State.Get(ctx, &state)
    
    id := state.ID.ValueString()
    
    // Multiple API calls!
    db, _ := r.client.GetDatabase(ctx, id)
    tags, _ := r.client.GetDatabaseTags(ctx, id)      // Extra call
    status, _ := r.client.GetDatabaseStatus(ctx, id)  // Extra call
    
    state.Name = types.StringValue(db.Name)
    state.Tags = convertTags(tags)
    state.Status = types.StringValue(status)
    
    resp.State.Set(ctx, state)
}

Batch Reads When Possible

If your API supports batch operations:

// Note: This is advanced and usually not needed
func (p *Provider) RefreshResources(ctx context.Context, ids []string) (map[string]*Database, error) {
    // Batch API call
    return p.client.BatchGetDatabases(ctx, ids)
}

Testing Read Operation

Test Refresh

func TestDatabaseResource_Read(t *testing.T) {
    resource.Test(t, resource.TestCase{
        ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
        Steps: []resource.TestStep{
            {
                // Create resource
                Config: testAccDatabaseConfig("test-db", 100),
                Check: resource.ComposeTestCheckFunc(
                    resource.TestCheckResourceAttr("mycloud_database.test", "storage_gb", "100"),
                ),
            },
            {
                // Simulate external change
                PreConfig: func() {
                    // Manually update storage via API
                    client.UpdateDatabase(ctx, "test-db", UpdateDatabaseInput{
                        StorageGB: 200,
                    })
                },
                // Refresh should detect change
                Config: testAccDatabaseConfig("test-db", 100),
                Check: resource.ComposeTestCheckFunc(
                    // State shows API value (200), plan will show drift
                    resource.TestCheckResourceAttr("mycloud_database.test", "storage_gb", "200"),
                ),
                ExpectNonEmptyPlan: true,  // Drift detected
            },
        },
    })
}

Test Not Found

func TestDatabaseResource_Read_NotFound(t *testing.T) {
    resource.Test(t, resource.TestCase{
        ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
        Steps: []resource.TestStep{
            {
                // Create resource
                Config: testAccDatabaseConfig("test-db", 100),
            },
            {
                // Delete externally
                PreConfig: func() {
                    client.DeleteDatabase(ctx, "test-db")
                },
                // Refresh should remove from state
                Config: testAccDatabaseConfig("test-db", 100),
                // Resource will be recreated
                ExpectNonEmptyPlan: true,
            },
        },
    })
}

Manual Refresh Command

terraform refresh

Users can explicitly refresh without planning:

# Refresh all resources
$ terraform refresh

mycloud_database.db: Refreshing state... [id=db-12345]

# State updated, no changes applied

When to use:

  • Verify state matches reality
  • Update state after manual changes
  • Debugging state issues

Note: terraform refresh is being deprecated in favor of:

terraform apply -refresh-only

Best Practices

1. Always Handle Not Found

// ✅ Good: Remove from state
if client.IsNotFoundError(err) {
    resp.State.RemoveResource(ctx)
    return
}

// ❌ Bad: Return error
if client.IsNotFoundError(err) {
    resp.Diagnostics.AddError("Not Found", "Resource deleted")
    return
}

2. Update All Attributes

// ✅ Good: Update everything
state.Name = types.StringValue(db.Name)
state.Engine = types.StringValue(db.Engine)
state.StorageGB = types.Int64Value(int64(db.StorageGB))
state.Status = types.StringValue(db.Status)
// ... all attributes

// ❌ Bad: Selective updates
state.StorageGB = types.Int64Value(int64(db.StorageGB))
// Skipping other attributes!

3. Handle Null Values

// ✅ Good: Proper null handling
if db.Description == "" {
    state.Description = types.StringNull()
} else {
    state.Description = types.StringValue(db.Description)
}

// ❌ Bad: Empty string instead of null
state.Description = types.StringValue(db.Description)  // "" not null

4. Log Refresh Operations

// ✅ Good: Log for debugging
tflog.Debug(ctx, "Refreshing database", map[string]any{
    "id": state.ID.ValueString(),
})

db, err := r.client.GetDatabase(ctx, state.ID.ValueString())

tflog.Info(ctx, "Refreshed database", map[string]any{
    "id":     db.ID,
    "status": db.Status,
})

5. Minimize API Calls

// ✅ Good: One API call
db, err := r.client.GetDatabase(ctx, id)  // Returns all data

// ❌ Bad: Multiple calls
db, _ := r.client.GetDatabase(ctx, id)
tags, _ := r.client.GetTags(ctx, id)
status, _ := r.client.GetStatus(ctx, id)

Key Takeaways

  • ✅ Read refreshes state before plan/apply
  • ✅ Always update ALL attributes from API
  • ✅ Remove from state when not found (don’t error)
  • ✅ Handle null and empty values correctly
  • ✅ Update nested objects and lists completely
  • ✅ Handle API errors appropriately
  • ✅ Minimize API calls in Read
  • ✅ Log refresh operations
  • ✅ Test refresh and not-found scenarios
  • ✅ Read is called automatically, users rarely call refresh manually

What’s Next?

Learn how to detect and handle configuration drift.

Next: Drift Detection →


Previous: ← Resource Import

Last updated: July 1, 2026

Updated: