Drift Detection

9 minute read

What is Configuration Drift?

Configuration drift occurs when actual infrastructure state differs from what Terraform expects based on configuration and state.

Causes of drift:

  • Manual changes via console/API
  • Changes by other automation tools
  • External systems modifying resources
  • Auto-scaling or auto-healing systems
  • Security patches and updates
┌──────────────────────────────────────────────────┐
│              Configuration Drift                  │
├──────────────────────────────────────────────────┤
│                                                   │
│  Configuration    State File      Reality        │
│  (What you want)  (Last known)    (What exists)  │
│                                                   │
│  storage_gb = 100  storage_gb: 100  storage_gb: 200│
│                       ▲                ▲          │
│                       └────────────────┘          │
│                           DRIFT!                  │
│                                                   │
│  terraform plan detects this difference           │
│  terraform apply reconciles it                    │
│                                                   │
└──────────────────────────────────────────────────┘

How Drift Detection Works

The Refresh Phase

terraform plan execution:
1. Read state file (storage_gb: 100)
2. Call Read() to refresh from API (storage_gb: 200)
3. Update state (storage_gb: 200)
4. Compare state vs configuration (200 != 100)
5. Show drift and plan changes

Your Provider’s Role

Read operation detects drift:

func (r *DatabaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
    var state DatabaseResourceModel
    req.State.Get(ctx, &state)
    
    // Fetch current reality from API
    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 state with actual values - this enables drift detection
    state.StorageGB = types.Int64Value(int64(db.StorageGB))  // May differ from config!
    state.Status = types.StringValue(db.Status)
    state.Tags = convertTags(db.Tags)
    
    resp.State.Set(ctx, state)
}

Terraform detects drift:

$ terraform plan

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

Terraform detected drift:

  # mycloud_database.db was modified outside Terraform
  ~ resource "mycloud_database" "db" {
        id         = "db-12345"
      ~ storage_gb = 200 -> 100  # Will be restored
        # ...
    }

Types of Drift

1. Modified Attributes

Resource exists but attributes changed:

# Configuration
resource "mycloud_database" "db" {
  name       = "production-db"
  storage_gb = 100  # Want 100
  engine     = "postgres"
}

Someone manually scaled to 200 GB:

$ terraform plan

Terraform detected the following changes outside Terraform:

  # mycloud_database.db has been modified
  ~ resource "mycloud_database" "db" {
        id         = "db-12345"
        name       = "production-db"
      ~ storage_gb = 200 -> 100
        # ...
    }

Terraform will perform the following actions:

  # mycloud_database.db will be updated in-place
  ~ resource "mycloud_database" "db" {
        id         = "db-12345"
      ~ storage_gb = 200 -> 100  # Revert to config
    }

Plan: 0 to add, 1 to change, 0 to destroy.

2. Deleted Resources

Resource removed outside Terraform:

$ terraform plan

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

Terraform detected drift:

  # 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
    }

Plan: 1 to add, 0 to change, 0 to destroy.

3. Added Tags

Tags added outside Terraform:

resource "mycloud_database" "db" {
  name = "prod-db"
  
  tags = {
    Environment = "production"
  }
}

Someone added a tag via console:

$ terraform plan

Terraform detected drift:

  # mycloud_database.db was modified
  ~ resource "mycloud_database" "db" {
      ~ tags = {
          ~ "CostCenter"  = "1234" -> null  # Will be removed
            "Environment" = "production"
        }
    }

Plan: 0 to add, 1 to change, 0 to destroy.

4. Computed Attribute Changes

Auto-scaling, auto-healing, or background processes:

resource "mycloud_cluster" "app" {
  name       = "app-cluster"
  desired_nodes = 3
  # actual_nodes is computed
}
$ terraform plan

mycloud_cluster.app: Refreshing state...

# No changes - actual_nodes is computed, not managed

Implementing Drift Detection

Refresh All Managed Attributes

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
    }
    
    // Refresh ALL managed attributes for drift detection
    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.MultiAZ = types.BoolValue(db.MultiAZ)
    state.PubliclyAccessible = types.BoolValue(db.PubliclyAccessible)
    
    // Refresh tags - important for drift detection!
    if len(db.Tags) > 0 {
        tagsMap := make(map[string]attr.Value)
        for k, v := range db.Tags {
            tagsMap[k] = types.StringValue(v)
        }
        state.Tags, _ = types.MapValue(types.StringType, tagsMap)
    } else {
        state.Tags = types.MapNull(types.StringType)
    }
    
    // Don't refresh computed-only attributes in state
    // They're already set from Create/Update
    // state.Endpoint - keep existing
    // state.Status - keep existing (or update if you want to show it)
    
    resp.State.Set(ctx, state)
    
    tflog.Debug(ctx, "Refreshed database", map[string]any{
        "id":         db.ID,
        "storage_gb": db.StorageGB,
        "multi_az":   db.MultiAZ,
    })
}

Detecting Deleted Resources

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()
    
    tflog.Debug(ctx, "Checking if database exists", map[string]any{
        "id": id,
    })
    
    db, err := r.client.GetDatabase(ctx, id)
    
    if err != nil {
        if client.IsNotFoundError(err) {
            // Resource deleted outside Terraform
            tflog.Warn(ctx, "Database not found, removing from state", map[string]any{
                "id": id,
            })
            resp.State.RemoveResource(ctx)
            return
        }
        
        // Other errors
        resp.Diagnostics.AddError(
            "Read Failed",
            fmt.Sprintf("Error reading database %s: %s", id, err.Error()),
        )
        return
    }
    
    tflog.Info(ctx, "Database exists", map[string]any{
        "id":     db.ID,
        "status": db.Status,
    })
    
    // Update state...
}

Handling Tag Drift

Tags are commonly modified outside Terraform:

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
    }
    
    // Get current tags from configuration
    var configTags map[string]string
    if !state.Tags.IsNull() {
        state.Tags.ElementsAs(ctx, &configTags, false)
    }
    
    // Refresh tags from API
    apiTags := db.Tags
    
    tflog.Debug(ctx, "Tag comparison", map[string]any{
        "config_tags": configTags,
        "api_tags":    apiTags,
    })
    
    // Convert API tags to Terraform format
    if len(apiTags) > 0 {
        tagsMap := make(map[string]attr.Value)
        for k, v := range apiTags {
            tagsMap[k] = types.StringValue(v)
        }
        state.Tags, _ = types.MapValue(types.StringType, tagsMap)
    } else {
        state.Tags = types.MapNull(types.StringType)
    }
    
    // Terraform will detect drift if API tags differ from config
    
    resp.State.Set(ctx, state)
}

Drift Detection Scenarios

Scenario 1: Storage Scaled Manually

Initial:

resource "mycloud_database" "db" {
  name       = "prod-db"
  storage_gb = 100
}

Drift:

# Someone scales via console
$ mycloud-cli update-database db-12345 --storage-gb 200

$ terraform plan
mycloud_database.db: Refreshing state...

Terraform detected drift and will restore config:

  ~ storage_gb = 200 -> 100

$ terraform apply
# Scales back to 100 GB

Scenario 2: Accepting Drift

If you want to keep manual change, update configuration:

resource "mycloud_database" "db" {
  name       = "prod-db"
  storage_gb = 200  # Updated to match reality
}
$ terraform plan
No changes. Your infrastructure matches the configuration.

Scenario 3: Ignored Attributes

Use lifecycle rules to ignore certain attributes:

resource "mycloud_database" "db" {
  name       = "prod-db"
  storage_gb = 100
  
  tags = {
    Environment = "production"
  }
  
  lifecycle {
    ignore_changes = [
      tags["CostCenter"],  # Allow manual changes
      storage_gb,          # Don't revert manual scaling
    ]
  }
}
$ terraform plan
# Changes to storage_gb and CostCenter tag ignored
No changes.

Advanced Drift Patterns

Partial Drift Detection

Only refresh attributes that matter:

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
    }
    
    // Refresh configurable attributes (detect drift)
    state.StorageGB = types.Int64Value(int64(db.StorageGB))
    state.MultiAZ = types.BoolValue(db.MultiAZ)
    state.Tags = convertTags(db.Tags)
    
    // Always update computed attributes (not drift, just info)
    state.Status = types.StringValue(db.Status)
    state.Endpoint = types.StringValue(db.Endpoint)
    state.ARN = types.StringValue(db.ARN)
    
    // Don't update immutable attributes
    // state.Engine stays from initial config
    // (API shouldn't allow changes anyway)
    
    resp.State.Set(ctx, state)
}

Drift in Nested Attributes

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 errors...
        return
    }
    
    // Refresh listeners - drift detection for nested objects
    listenerObjectType := types.ObjectType{
        AttrTypes: map[string]attr.Type{
            "port":     types.Int64Type,
            "protocol": types.StringType,
        },
    }
    
    listenerObjects := make([]attr.Value, len(lb.Listeners))
    for i, listener := range lb.Listeners {
        obj, _ := types.ObjectValue(
            listenerObjectType.AttrTypes,
            map[string]attr.Value{
                "port":     types.Int64Value(int64(listener.Port)),
                "protocol": types.StringValue(listener.Protocol),
            },
        )
        listenerObjects[i] = obj
    }
    
    state.Listeners, _ = types.ListValue(listenerObjectType, listenerObjects)
    
    resp.State.Set(ctx, state)
    
    tflog.Debug(ctx, "Refreshed load balancer", map[string]any{
        "id":             lb.ID,
        "listener_count": len(lb.Listeners),
    })
}

User Experience

Showing Drift

$ terraform plan

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

Note: Objects have changed outside of Terraform

Terraform detected the following changes made outside of Terraform
since the last "terraform apply" which may have affected this plan:

  # mycloud_database.db has been modified
  ~ resource "mycloud_database" "db" {
        id         = "db-12345"
      ~ storage_gb = 200 -> 100
      ~ tags       = {
          + "CostCenter" = "1234"  # Added outside Terraform
            "Environment" = "production"
        }
        # (8 unchanged attributes hidden)
    }

Unless you have made equivalent changes to your configuration, or
ignored the relevant attributes using ignore_changes, the following
plan may include actions to undo or respond to these changes.

Terraform will perform the following actions:

  # mycloud_database.db will be updated in-place
  ~ resource "mycloud_database" "db" {
      ~ storage_gb = 200 -> 100
      ~ tags       = {
          - "CostCenter" = "1234" -> null
            "Environment" = "production"
        }
    }

Plan: 0 to add, 1 to change, 0 to destroy.

Refresh-Only Mode

Check drift without planning changes:

$ terraform plan -refresh-only

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

Terraform detected changes:

  # mycloud_database.db has been changed
  ~ resource "mycloud_database" "db" {
      ~ storage_gb = 100 -> 200
        # ...
    }

This is a refresh-only plan. Terraform will update state to match
reality, but will not change any real infrastructure.

Apply to update state:

$ terraform apply -refresh-only
# State updated, configuration unchanged

Preventing Drift

1. Protect Critical Resources

Use lifecycle rules:

resource "mycloud_database" "prod" {
  name = "production-db"
  
  lifecycle {
    prevent_destroy = true  # Can't be destroyed
    
    ignore_changes = [
      tags,  # Allow manual tag changes
    ]
  }
}

2. Use Read-Only Permissions

IAM policies preventing manual changes:

{
  "Effect": "Deny",
  "Action": [
    "mycloud:UpdateDatabase",
    "mycloud:DeleteDatabase"
  ],
  "Resource": "arn:aws:mycloud:*:*:database/prod-*",
  "Condition": {
    "StringNotEquals": {
      "aws:PrincipalArn": "arn:aws:iam::123456789012:role/TerraformRole"
    }
  }
}

3. Automation Guardrails

Alert on drift detection:

#!/bin/bash
# drift-detector.sh

terraform plan -detailed-exitcode -refresh-only > /dev/null 2>&1

if [ $? -eq 2 ]; then
  echo "DRIFT DETECTED!"
  # Send alert
  aws sns publish \
    --topic-arn arn:aws:sns:us-east-1:123:drift-alerts \
    --message "Terraform drift detected in production"
fi

Best Practices

1. Always Refresh All Managed Attributes

// ✅ Good: Detect all drift
state.Name = types.StringValue(db.Name)
state.StorageGB = types.Int64Value(int64(db.StorageGB))
state.MultiAZ = types.BoolValue(db.MultiAZ)
state.Tags = convertTags(db.Tags)

// ❌ Bad: Incomplete refresh
state.StorageGB = types.Int64Value(int64(db.StorageGB))
// Missing name, multi_az, tags - drift won't be detected!

2. Remove Deleted Resources

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

// ❌ Bad: Leave in state
if client.IsNotFoundError(err) {
    tflog.Warn(ctx, "Not found")
    // Don't remove - drift won't be detected!
}

3. Log Drift Detection

// ✅ Good: Log for auditing
tflog.Warn(ctx, "Drift detected", map[string]any{
    "id":            db.ID,
    "expected_size": plan.StorageGB.ValueInt64(),
    "actual_size":   db.StorageGB,
})

4. Handle Computed Attributes

// Computed attributes: Always update (not drift)
state.Status = types.StringValue(db.Status)
state.Endpoint = types.StringValue(db.Endpoint)

// Managed attributes: Update for drift detection
state.StorageGB = types.Int64Value(int64(db.StorageGB))

5. Test Drift Scenarios

func TestDatabaseResource_Drift(t *testing.T) {
    resource.Test(t, resource.TestCase{
        ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
        Steps: []resource.TestStep{
            {
                Config: testAccDatabaseConfig(100),
                Check: resource.TestCheckResourceAttr("mycloud_database.test", "storage_gb", "100"),
            },
            {
                PreConfig: func() {
                    // Simulate manual change
                    client.UpdateDatabase(ctx, "test", UpdateInput{
                        StorageGB: 200,
                    })
                },
                Config:             testAccDatabaseConfig(100),
                ExpectNonEmptyPlan: true,  // Drift should be detected
            },
        },
    })
}

Key Takeaways

  • ✅ Drift occurs when reality differs from configuration
  • ✅ Read operation detects drift by refreshing from API
  • ✅ Refresh ALL managed attributes for complete drift detection
  • ✅ Remove deleted resources from state
  • ✅ Terraform shows drift before planning changes
  • ✅ Users can accept drift by updating configuration
  • ✅ Use ignore_changes to allow specific drift
  • ✅ Log drift for auditing and debugging
  • ✅ Test drift scenarios in provider tests
  • ✅ Computed attributes aren’t drift (they’re expected to change)

What’s Next?

Learn how to handle schema changes and state migrations.

Next: Schema Upgrades →


Previous: ← State Refresh

Last updated: July 1, 2026

Updated: