Skip to main content

Terraform · troubleshooting

Terraform wants to change something nobody changed

A plan proposes changes to resources you did not touch. Either reality moved out from under the state — somebody changed it in a console — or the provider is reporting a difference that is not really a difference. The two look identical in a plan and need opposite responses.

Run this first

step 1 of 3
terraform plan -refresh-only

Look for Changes here are genuine drift. An empty refresh-only plan alongside a non-empty normal plan means the difference is in configuration or normalisation, not in reality.

Separates "reality changed" from "Terraform wants to change something", which is the fork this whole investigation turns on.

Work out which cause you have

A few questions to narrow the list. Every answer ends in a command that confirms or rules the cause out — this cannot see your cluster, so nothing here is a certainty until you have checked.

Narrow it down

0 answered · nothing is sent anywhere

Does the plan show `-/+` or `forces replacement` anywhere?

Checked first because it is the only outcome here that can be unrecoverable.

Cause space

5 of 5 still possible

  • Somebody changed the resource outside TerraformCommon
  • The provider reports a value in a different form than you wrote itCommon
  • The resource no longer existsCommon
  • A provider or module upgrade changed the schemaOccasional
  • A change to an immutable attribute forces replacementOccasional

Nothing ruled out yet. Answer the question above and the branches your answer eliminates will strike through here.

Check it with a tool

Or diagnose it manually

In this order. The first command usually contains the whole answer.

  1. Step 1

    terraform plan -refresh-only

    Separates "reality changed" from "Terraform wants to change something", which is the fork this whole investigation turns on.

    Look for Changes here are genuine drift. An empty refresh-only plan alongside a non-empty normal plan means the difference is in configuration or normalisation, not in reality.

  2. Step 2

    terraform plan | grep -B5 'forces replacement'

    Checks for destruction before anything else, because that is the only outcome on this page that can be unrecoverable.

    Look for Any `-/+` on a resource holding data. Stop there and confirm.

  3. Step 3

    terraform plan && terraform apply && terraform plan

    The decisive test for normalisation: a change that survives its own apply was never drift.

    Look for An empty second plan means it was drift and is fixed. A repeat means normalisation.

Every cause, and how to fix it

Ordered by how often each one turns out to be the answer.

Somebody changed the resource outside Terraform

Common

The most common cause and usually well-intentioned: an urgent fix applied in the console during an incident, and never brought back into code. State still describes the old shape, so the next plan proposes undoing the fix.

Confirm

terraform plan -refresh-only

This shows what changed in reality without proposing to fix it — the cleanest way to see drift as drift. Then check the provider's audit log for who changed it and when.

Fix

  • Decide which side is right before touching anything. If the manual change was correct, bring it into configuration. If it was not, let the apply revert it.
  • Never adopt a manual change by editing state. Change the configuration so the code remains the source of truth.
  • For resources that legitimately change outside Terraform — autoscaled capacities, provider-managed tags — use `ignore_changes` on those specific attributes rather than tolerating a permanently noisy plan.
terraform plan -refresh-only   # see the drift without acting on it

# For attributes that legitimately change elsewhere:
lifecycle {
  ignore_changes = [tags["LastScanned"], desired_count]
}

The provider reports a value in a different form than you wrote it

Common

A JSON policy document re-serialised with different key order or whitespace, a name the API lowercases, a CIDR the provider expands. The values are semantically identical and textually different, so Terraform sees a change every time.

Confirm

terraform plan && terraform apply && terraform plan

Whether the second plan is empty. A change that survives its own apply is normalisation, not drift — real drift disappears once corrected.

Fix

  • Write the value in the form the provider returns. For policies, generating them with the provider's own data source removes the whole class of problem.
  • Upgrade the provider — these are common bugs and often already fixed.
  • Use `ignore_changes` only as a last resort here, since it also suppresses genuine changes to that attribute.

The resource no longer exists

Common

Deleted manually, expired, or removed by another tool. Terraform finds nothing at the recorded identifier and plans to create it — which reads as a mysterious addition rather than a deletion, because the plan describes what it intends rather than what happened.

Confirm

terraform state show <address>

The recorded ID, then check whether it exists in the provider. A create in the plan for something you believe exists means the state's ID no longer resolves.

Fix

  • If it should exist, let Terraform create it — but check for a name collision first, since a half-deleted resource can block creation.
  • If it was replaced by something equivalent, `terraform import` adopts the existing resource instead of creating a duplicate.
terraform import <address> <provider-id>

A provider or module upgrade changed the schema

Occasional

A new provider version adds a default, renames an attribute, or changes how one is computed. Nothing in your configuration or your infrastructure moved — the interpretation did. The tell is that the drift appeared with a version bump rather than with an infrastructure change.

Confirm

terraform version && grep -rn 'required_providers' -A5 . | head -20

Whether the lockfile or module version changed around when the drift appeared. Correlate with the plan's first appearance rather than with anything in the cloud.

Fix

  • Read the provider's upgrade notes for the attribute in question — these changes are usually documented.
  • If the new default is correct, adopt it in configuration so the plan settles.

A change to an immutable attribute forces replacement

Occasional

Some attributes cannot be updated in place, so any difference means destroy and create. On a database, a volume or anything holding data, applying that is the most expensive mistake available on this page — and the plan says so in one easily-missed line.

Confirm

terraform plan | grep -B5 'forces replacement'

The attribute annotated `# forces replacement`. That one line is the difference between an update and a rebuild.

Fix

  • Stop and confirm this is intended before applying. `-/+` on a stateful resource deserves a second person looking at it.
  • If the attribute must change, plan the migration deliberately — snapshot, create alongside, cut over — rather than letting a single apply do it.
  • `prevent_destroy` on resources that must never be replaced turns this from a judgement call into a hard stop.
lifecycle {
  prevent_destroy = true
}

Makes an accidental replacement fail the plan rather than proceeding.

Understanding it properly

Skip this if you are mid-incident — the working part of the page is above. Worth reading afterwards, because understanding the mechanism is what stops the next one.

What is actually happening

Terraform decides what to do by comparing three things: your configuration, its state, and reality as the provider reports it. A plan you did not expect means two of those three disagree, and which two changes everything about the right response.

**Reality moved.** Somebody adjusted a security group in the console, an autoscaler changed a capacity, another tool wrote a tag. State says one thing, the API says another, and Terraform proposes putting it back. That is drift, and Terraform is behaving correctly.

**Nothing moved.** The provider normalises a value differently than you wrote it, a default appeared that was previously unset, or an attribute is computed and reported in a different form. The plan is noise, and applying it changes nothing while training everybody to skim plans.

The distinction to look at first is whether the plan says `~` (update in place) or `-/+` (destroy and create). A forced replacement is worth stopping for regardless of cause, because applying one on a stateful resource can be unrecoverable.

How to tell this is your problem
WhereWhat you see
terraform planChanges to resources absent from your diff, often with `# forces replacement` on one attribute.
Cloud provider audit logA console or CLI action against the resource, by a human or another automation.
Repeated plansThe same change proposed again after a successful apply — the signature of a normalisation problem rather than drift.

How to know it is actually fixed

  • `terraform plan` is empty, and stays empty on a second run — the second run is what distinguishes a fix from a temporary suppression.
  • If the manual change was adopted, it is visible in configuration rather than only in state.
  • For a forced replacement that was intended, the dependent resources still work — a replaced resource usually gets a new identifier.

Stopping it happening again

  • Run a scheduled refresh-only plan and alert on non-empty output. Drift found the day it happens is a tidy-up; drift found during an unrelated urgent change is an incident.
  • Remove or restrict console write access for resources Terraform manages. It is the only prevention that addresses the cause rather than the symptom.
  • Use `ignore_changes` deliberately for attributes that legitimately change elsewhere, so a noisy plan does not train the team to skim.
  • Put `prevent_destroy` on anything holding data, so an accidental replacement fails rather than proceeding.

Did this get you to an answer?

No text box on purpose — please do not paste production logs anywhere

Sources

Behaviour described here is drawn from official documentation. Where a figure could not be confirmed on an official page it is attributed in the text rather than stated as canonical.

Related

Terraform wants to change something nobody changed — causes, diagnosis and fix | DevOps Insights