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