Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 073 / 100

terraform plan shows changes every time, explained simply

Fix perpetual diffs.

Expona Admin6 min read

The perpetual diff problem

You run terraform plan on a resource that shouldn't have changed, and it reports modifications. You apply it, run plan again moments later, and the same changes show up. This is one of the most frustrating experiences in infrastructure-as-code workflows—and it's surprisingly common. When terraform plan shows changes every time, explained simply, it usually comes down to a mismatch between what Terraform expects and what the cloud provider actually returns.

We've debugged this across multiple cloud platforms and Terraform versions. The root causes fall into a small number of patterns, and once you recognize them, they become straightforward to fix. Let's walk through the main culprits and how to eliminate them.

Provider normalizes values differently

Cloud providers store and return data in specific formats. Your Terraform configuration might express the same value differently than the API does. When the provider reads the resource back from the cloud, it returns the provider's canonical format, and if that doesn't match your config, Terraform thinks something changed.

A classic example: JSON objects. AWS stores JSON as strings in many places. You might write:

hcl
resource "aws_iam_role_policy" "example" {
  name   = "my-policy"
  role   = aws_iam_role.example.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = "s3:GetObject"
        Resource = "arn:aws:s3:::my-bucket/*"
      }
    ]
  })
}

You run plan. It shows no changes. But your teammate manually edits the policy via the console to add a whitespace change, or AWS normalizes the JSON formatting. Next plan run shows a diff even though the policy is logically identical.

The fix is to normalize on the Terraform side before comparison:

hcl
resource "aws_iam_role_policy" "example" {
  name   = "my-policy"
  role   = aws_iam_role.example.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = "s3:GetObject"
        Resource = "arn:aws:s3:::my-bucket/*"
      }
    ]
  })
}

output "policy_normalized" {
  value = jsondecode(aws_iam_role_policy.example.policy)
}

Better yet, use sensitive = true if you're not reading the policy back, or better yet—ensure your Terraform provider is new enough to handle this normalization automatically.

Another normalization issue: boolean strings. Some APIs accept true, "true", 1, or "1" and normalize them all to the same internal representation. If you specify true in HCL but the API returns "true", you get a perpetual diff. Check your provider's documentation for which format it uses.

Use ignore_changes for fields managed elsewhere

Sometimes a field is managed outside Terraform—by an automated process, a human operator, or a complementary tool. You want Terraform to create the resource and manage most fields, but not care about that one field changing.

Say you provision an RDS instance with Terraform, but your database operations team occasionally patches the engine version manually during maintenance windows. Without intervention, your next plan shows the version has drifted and Terraform will try to revert it:

hcl
resource "aws_db_instance" "main" {
  identifier           = "my-postgres-db"
  engine               = "postgres"
  engine_version       = "14.7"
  allocated_storage    = 100
  instance_class       = "db.t3.medium"
  skip_final_snapshot  = true
  
  lifecycle {
    ignore_changes = [engine_version]
  }
}

Now Terraform won't complain if the engine version changes outside of Terraform. The database team can patch freely. If you later update engine_version in your config, Terraform will apply that change, but it won't try to revert manual updates.

Use this pattern cautiously. It's a safety valve for known external management, not a band-aid for configuration problems. Document why a field is ignored—your future self will thank you. In a team environment, it signals to others that this field is managed externally.

A more granular approach: ignore changes only if they match certain patterns. For example, ignore timestamp-like fields that the provider auto-updates:

hcl
lifecycle {
  ignore_changes = [
    tags["ManagedBy"],
    updated_at,
    last_modified_time
  ]
}

Match the exact format the API returns

This is about being precise in your configuration. Terraform compares the state (what the provider returns) with your configuration (what you wrote). If they're not in the same format, you get a diff.

Common format mismatches:

  • Port numbers: You specify 443 (integer), the API returns "443" (string) or vice versa. Check your resource schema.
  • Lists vs. single values: A field accepts a list but you provide a string. The provider might auto-wrap it in a list, then return it as a list, creating a diff if your config still has the string.
  • Default values: The API sets defaults for fields you didn't specify. When the provider reads the resource, those defaults appear in the state. If your config omits them, there's a diff.
  • Whitespace and special characters: YAML, JSON, and other serialized formats are sensitive to formatting. Ensure you're matching exactly what the API expects.

Example with security groups and CIDR blocks:

hcl
resource "aws_security_group_rule" "allow_https" {
  type              = "ingress"
  from_port         = 443
  to_port           = 443
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.web.id
}

Run plan. If you see a diff on cidr_blocks, check how the API returns it. Some APIs sort CIDR blocks alphabetically, some don't. Use the -json flag to see the exact diff:

hcl
terraform plan -json | jq '.resource_changes[] | select(.type=="aws_security_group_rule")'

This shows you exactly what Terraform thinks has changed. Use that output to adjust your config to match.

Upgrade the provider — often already fixed

Provider maintainers are constantly fixing normalization issues and perpetual diff bugs. If you're on an older version, the problem might already be solved in a newer release.

Check your provider version constraints:

hcl
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

If you're locked to a narrow version range, consider updating. The tilde allows patch and minor updates, but if you're on version 4.x and there's a 5.x, review the changelog for relevant fixes.

Before upgrading, test in a non-production environment. Run a plan, check for unexpected changes, and only then apply. Provider upgrades can sometimes introduce new behaviors.

If the perpetual diff appeared suddenly, check recent provider release notes. A small fix might have broken your specific use case—or fixed it. The community forums and GitHub issues are invaluable here. Search for your resource type and the field name; someone else has likely hit the same issue.

Common mistakes

  • Not checking the actual API response: You assume the API returns data in your format. Always verify with terraform state show or provider debug logs.
  • Applying ignore_changes too broadly: Using ignore_changes = all defeats the purpose of Terraform. Be specific about which fields are managed externally.
  • Mixing managed and unmanaged resources: If one team uses Terraform and another uses CloudFormation or the console, perpetual diffs are inevitable. Establish clear ownership.
  • Not committing normalized state: After fixing a perpetual diff, run terraform apply once to update the state, then commit it. This prevents your teammates from seeing false diffs.
  • Ignoring provider version pinning: A colleague upgrades the provider locally, fixes the diff, but didn't update versions.tf. You pull their changes and still see the diff because your provider is older.

Key takeaways

  • Perpetual diffs are almost always a mismatch between Terraform's config format and the provider's state format.
  • Use terraform plan -json and terraform state show to see exactly what the provider is reporting.
  • Normalize data on the Terraform side (JSON, booleans, types) to match what the API returns.
  • Apply ignore_changes only for fields genuinely managed outside Terraform, and document why.
  • Keep providers updated; many perpetual diffs are provider bugs that are already fixed in newer versions.
  • Ensure your team uses the same provider versions, or diffs will appear inconsistently across machines.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles