Skip to main content
DevOps Insightsguides & answers

Terraform · troubleshooting

Terraform wants to change something nobody changed

A plan proposes changes to resources you did not touch: reality moved out from under the state, or the provider reports a difference that is not real. Reality moves when, for example, somebody changes something in a console. The two look identical in a plan and need opposite responses.

Reproduced on Terraform 1.14.0, . Where the text says “reproduced”, the behaviour was run on that version, not taken from documentation.

First published

New to this? Read what is actually happening first, then come back to the steps.

On this page · 10 sections

Before you start

  • A terminal in the same working directory and workspace as the surprising plan, with terraform init done and the same Terraform and provider versions as your pipeline, so you read the same state.
  • Provider credentials that can read every resource in that state. A refresh reads each one, and HashiCorp warns that with misconfigured credentials Terraform may conclude that managed objects were deleted.
  • Read access to your cloud provider's audit log for the time since the last apply, to see who changed what and when.
  • The log or plan of the last successful apply, so you know what the infrastructure was meant to look like.
  • Agreement from the resource's owner before you either revert a manual change or copy it into configuration.

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 -/+, +/-, must be replaced or will be destroyed 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 -no-color | grep -E 'be replaced|be destroyed|^Plan:'

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

    Look for A must be replaced, will be replaced or will be destroyed heading on a resource holding data, or a Plan: line with anything but 0 to destroy. Stop there and confirm.

  3. Step 3

    terraform plan -out=tfplan   # read it: no -/+ or destroy
    terraform apply tfplan        # this changes real infrastructure
    terraform plan                # empty = fixed; the same change again = normalisation

    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 -out=tfplan && terraform apply tfplan && terraform plan   # applies real changes: read the first plan before approving

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. Terraform 1.14 also lists the object under Note: Objects have changed outside of Terraform as has been deleted when that change affects the plan, which a deletion that forces a re-create does; read that note before the create, so the addition is not a mystery.

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 state rm <address>        # or: terraform apply -refresh-only, which drops the deleted object
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 -no-color | grep -E 'be replaced|be destroyed|^Plan:'

A heading that says must be replaced (or will be replaced), with -/+ or +/- beside the resource. Providers usually mark the attribute responsible with # forces replacement, but not every resource does (Terraform's built-in terraform_data does not), so search for the heading rather than the comment.

Fix

  • Stop and confirm this is intended before applying. -/+ or +/- 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.

In plain terms

Terraform keeps a record, called state, of what it last built, and checks the real infrastructure against it before every plan. If someone changed a resource by hand, the plan offers to put it back, which looks like a change nobody asked for. Sometimes nothing really changed and the provider just reports the same value in a different form, so the first job is to find out which.

What drift looks like in a refresh-only plan

From HashiCorp's "Manage resource drift" tutorial, not reproduced here, because drift needs a real provider and a real remote object. The tutorial swapped an EC2 instance's security group using the AWS CLI, then ran a refresh-only plan. It used AWS provider v3.26.0 and an older Terraform. In Terraform 1.14's source the heading reads has changed instead of has been changed, and the line under the note ends which may have affected this plan:. Shown are the first 15 lines of the drift section, as published.

Command

terraform plan -refresh-only

Output · as shown in Terraform tutorial: Manage resource drift

Note: Objects have changed outside of Terraform

Terraform detected the following changes made outside of Terraform since the last "terraform apply":

  # aws_instance.example has been changed
  ~ resource "aws_instance" "example" {
        id                           = "i-008bef01721ee7f7c"
        tags                         = {
            "Name"          = "terraform-learn-state-ec2"
            "drift_example" = "v1"
        }
      ~ vpc_security_group_ids       = [
          + "sg-0226a51361bf1497a",
          - "sg-0b318a348a4a4e391",
        ]

How to read it

  • Note: Objects have changed outside of Terraform starts the list of drift. Everything under it describes what already happened, not what Terraform will do.
  • # aws_instance.example has been changed with ~ means the object still exists but its settings differ. An object removed outside Terraform is listed as has been deleted instead.
  • In vpc_security_group_ids, - is the value state recorded and + is the value the provider reports now: the old SSH group was swapped for the new one.
  • Further down, the tutorial's output says this is a refresh-only plan and Terraform will not take any actions to undo these. terraform apply -refresh-only would only record the new value in state, while a normal plan or apply would try to revert the manual change.

Words used on this page

State
Terraform's record of the real objects it manages and which resource block each one belongs to, by default the file terraform.tfstate. Terraform refreshes it from the real infrastructure before each plan.
Drift
A difference between state and the real infrastructure, caused by a change made outside Terraform, for example in a web console or by another tool.
Refresh
Terraform asking the provider for the current settings of every managed object, so the plan starts from what really exists.
Refresh-only plan
terraform plan -refresh-only: a plan that only reports what changed outside Terraform and offers to update state to match. It never proposes changes to infrastructure.
Replacement
A planned destroy and re-create of one object, under a heading such as must be replaced. -/+ destroys first; +/- creates the new object first, which is what create_before_destroy asks for.
ignore_changes
A lifecycle setting listing attributes Terraform should ignore when planning updates, so changes made to them elsewhere are left alone.
prevent_destroy
A lifecycle setting that makes any plan that would destroy that resource, including a replacement, fail with an error.
Normalisation
A provider returning a value in a different but equivalent form from the one you wrote, such as reordered JSON. The text differs, so Terraform plans a change.
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 -/+ or +/- (destroy and create, in either order). 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.

Security considerations

  • Check the audit log before adopting drift into configuration. A change made outside Terraform may be an unauthorised one, such as an opened port, and copying it into code makes it permanent.
  • Read a refresh-only plan before applying it, and do not use terraform refresh, which is terraform apply -refresh-only -auto-approve. HashiCorp warns that with misconfigured credentials Terraform may think every managed object was deleted and remove them all from state without a prompt.
  • Keep ignore_changes off security settings such as firewall rules and access policies. Terraform ignores the listed attributes when planning updates, so later tampering with them stops showing up in any plan.
  • prevent_destroy blocks replacements but not removal of the resource block. Reproduced on Terraform 1.14.0: with the block deleted, the plan destroyed the object with no error, as HashiCorp's lifecycle page says. Keep backups and the provider's own deletion protection on data stores as well.
  • Plan output shows attribute values, and HashiCorp says a saved plan file stores sensitive values in cleartext. Redact IDs and values before sharing drift output, and keep plan files as private as state.

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.

Terms used here (1)

Short definitions for the vocabulary on this page, in case any of it is new. Each links to the full entry.

Infrastructure as Code
The practice of managing and provisioning infrastructure through machine-readable configuration files rather than manual processes. IaC tools like Terraform and Ansible make infrastructure reproducible, version-controlled, and auditable.

All 59 terms in the glossary

Did this get you to an answer?

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

Something wrong or out of date? Report it