03/24/26

Why Terraform Plan Shows Changes When Nothing Changed

Diagnosing and fixing phantom diffs in Terraform

6 Min Read

Phantom diffs are terraform plan changes that show up even though nobody modified the infrastructure or the config. A JSON policy gets reported as changed because the key ordering differs. A timestamp field regenerates on every plan. Tags appear on resources from org-level AWS policies that Terraform didn't set. The resources are configured exactly the way you want them, but Terraform's comparison logic disagrees.

This guide covers the common causes, how to fix each one, and why the state-comparison model makes phantom diffs inevitable. Infrastructure-from-code tools like Encore sidestep the issue by deriving infrastructure from application code rather than comparing against a state file.

What phantom diffs are

A phantom diff occurs when terraform plan reports a change on a resource attribute even though the infrastructure is configured exactly the way you intended. The actual cloud resource matches what you want, but Terraform's comparison logic sees a difference between the value in state and the value it computes from your configuration or gets back from the provider API.

This is different from drift, where something genuinely changed outside of Terraform. With phantom diffs, nothing changed at all. The diff is an artifact of how Terraform and its providers serialize, normalize, and compare attribute values.

Phantom diffs are more than a cosmetic annoyance. When your plan output always contains noise, you stop reading it carefully. Real changes hide among the false positives. Teams start approving apply runs without reviewing diffs because they've learned the diffs are meaningless, and that habit eventually lets an actual mistake through.

Common causes

Phantom diffs come from several sources, and the fix depends on which one you're dealing with.

Provider bugs in attribute computation. Cloud providers return resource attributes in formats that don't always round-trip cleanly through Terraform's state. An AWS provider might store a Lambda function's environment variables in one order, but the API returns them in a different order on the next read. The values are identical, but the serialized form changed, so Terraform flags a diff. These bugs get fixed in provider releases, but new ones appear regularly. The AWS provider changelog has entries for this category of fix in nearly every release.

Default values that Terraform doesn't track. Some resource attributes have defaults that the cloud provider applies but Terraform doesn't include in state. If you omit an optional field, the provider fills it in server-side. On the next plan, Terraform compares your config (which doesn't specify the field) against the API response (which includes the default), and sees a difference. Each subsequent plan shows the same "change" because Terraform never records the default in state.

JSON field ordering. Several resources accept JSON strings as attributes, particularly IAM policies in AWS. If you write your policy inline as a string, Terraform stores it exactly as written. When the API returns the same policy with keys in a different order, Terraform sees a diff. This happens consistently, producing the same phantom change on every plan.

Timestamp and hash fields that regenerate. Some resources include attributes like last_modified, etag, or computed hashes that change on every API read even when the underlying resource hasn't been modified. The provider reads a new timestamp each time, and Terraform treats it as a change that needs to be applied.

Tags added by organization-level policies. AWS Organizations, Azure Policy, and GCP organization constraints can automatically attach tags or labels to resources after creation. Terraform doesn't know about these policies, so it sees tags in the API response that aren't in your config and reports them as drift. On the next apply, Terraform removes the tags. The org policy adds them back. The next plan shows the tags again. This cycle produces a phantom diff on every run.

ignore_changes not set on auto-managed fields. Resources managed by auto-scalers, deployment controllers, or other automation commonly have attributes that change outside Terraform's control. Without a lifecycle block telling Terraform to skip those attributes, every plan picks up changes that the auto-scaler made since the last apply.

How to fix them

Use jsonencode() instead of inline JSON strings. For IAM policies and any other JSON attributes, using Terraform's jsonencode function ensures consistent serialization. Terraform controls the output format, so the value in state will match what the provider returns.

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

Add lifecycle blocks for fields you don't control. If an attribute is managed by an auto-scaler, an org policy, or another external process, tell Terraform to ignore it:

resource "aws_instance" "example" { # ... lifecycle { ignore_changes = [tags["CreatedBy"], tags["CostCenter"]] } }

For auto-scaling groups, ignoring desired_capacity is almost always necessary. For ECS services, task_definition often needs the same treatment if deployments happen outside Terraform.

Pin and update provider versions deliberately. Many phantom diffs are provider bugs that get fixed in subsequent releases. Check the provider changelog when you see an unexplained diff. If a fix exists, update to that version. If it doesn't, pin your current version and open an issue. Upgrading providers blindly can also introduce new phantom diffs, so review changelogs before bumping versions in production.

Run terraform plan after terraform apply to confirm. If you apply and immediately plan again, any diffs in the second plan are almost certainly phantom. A real change would have been resolved by the apply. This is a quick diagnostic to separate phantom diffs from genuine drift.

When to report vs. when to work around

If a phantom diff is clearly a provider bug (the attribute value is functionally identical but serialized differently), search the provider's GitHub issues first. These tend to be well-known problems with active discussions. If you don't find an existing issue, filing one with the output of terraform plan and your provider version helps the maintainers prioritize a fix.

For diffs caused by org-level tag policies, the fix is almost always ignore_changes. This isn't a provider bug; it's two systems with conflicting ownership of the same attribute. Adding the tags to your Terraform config is another option, but only if the tags are stable and you want Terraform to enforce them.

If a phantom diff is harmless and you've confirmed it doesn't affect actual infrastructure, ignore_changes is a reasonable long-term fix. Document why you're ignoring the field with a comment in the code, so the next person doesn't remove the lifecycle block and reintroduce the noise.

The state-comparison model is the root cause

Phantom diffs exist because Terraform's architecture relies on comparing a local state representation against API responses. Every attribute of every resource goes through a serialize-store-read-compare cycle, and any inconsistency in that cycle produces a false diff. Providers have to handle format normalization for hundreds of attributes across thousands of resource types. Some attributes will always have edge cases.

Infrastructure-from-code tools like Encore avoid this category of problem by removing the state-comparison model entirely. Instead of maintaining a separate state file that describes what infrastructure should look like, infrastructure is declared in application code and provisioned through a platform that manages the full lifecycle. There's no local state that can diverge from reality, and no serialization layer where formatting mismatches produce false diffs.

This doesn't make phantom diffs a reason to abandon Terraform. They're manageable with the techniques above. But if your team spends time every week triaging plan output to separate real changes from noise, the operational cost adds up, and it's worth understanding that the problem is structural rather than incidental.

Ready to escape the maze of complexity?

Encore Cloud is the development platform for building robust type-safe distributed systems with declarative infrastructure.