TerraformTerraform

Error: Saved plan is stale

State changed between plan and apply, so the plan no longer describes reality. Why Terraform refuses it, and how to structure a pipeline so it stops happening.

medium fix5 min read

the terraform error
Error: Saved plan is stale
The given plan file can no longer be applied because the state was changed by another operation after the plan was created.

Error: Saved plan does not match configuration

Do this first3 steps

Run these in order. Each one tells you what its output means before you change anything.

  1. 1

    Check whether the state really moved

    terraform state pull | python3 -c "import sys,json; d=json.load(sys.stdin); print('serial', d['serial'], 'lineage', d['lineage'])"

    The serial increments on every state write. Comparing it before and after tells you whether someone else applied in between, which is the usual cause rather than anything wrong with your plan.

  2. 2

    See who wrote to state most recently

    aws s3api list-object-versions --bucket acme-tfstate --prefix prod/terraform.tfstate --max-items 5 --query 'Versions[].{Modified:LastModified,Id:VersionId}' --output table

    With a versioned S3 backend the object history shows each write and when. A write between your plan and your apply confirms a concurrent operation.

  3. 3

    Re-plan and apply in one operation

    terraform plan -out=tfplan && terraform apply tfplan

    The gap between the two commands is the window. Narrowing it, or eliminating manual approval in between, removes most occurrences. Do not skip the re-plan and apply the old file.

All 9 sections

A saved plan is a promise about a specific state. Terraform records the state serial when the plan is created, and refuses to apply if that serial has moved, because the plan's assumptions may no longer hold.

This is a safety feature. Applying a stale plan could destroy something created since, or fail halfway through on a resource that already changed.

Confirm the state moved

terraform state pull | python3 -c "import sys,json; d=json.load(sys.stdin); print('serial', d['serial'])"

The serial increments on every write. If it is higher than when you planned, something applied in between.

With a versioned S3 backend you can see exactly when:

aws s3api list-object-versions --bucket acme-tfstate \
  --prefix prod/terraform.tfstate --max-items 5 \
  --query 'Versions[].{Modified:LastModified,Id:VersionId}' --output table

What changed it

Another apply. A colleague, or a second pipeline run. The most common cause by a wide margin.

A pipeline with manual approval. Plan runs, waits for a human, and someone else merges and deploys in the meantime. This is the classic CI shape that produces this error routinely.

terraform state commands. state rm, state mv and import all write state and bump the serial.

A refresh. terraform refresh, or terraform plan run with -refresh=true in another shell, writes drift into state.

The fix

Re-plan and apply together:

terraform plan -out=tfplan
terraform apply tfplan

Do not apply the old plan file. Terraform is refusing for a reason; there is no -force and adding one would be a mistake. The correct response is always a fresh plan.

Structuring a pipeline so it stops

The safest shape keeps plan and apply in one job:

# GitLab CI
apply:
  stage: deploy
  script:
    - terraform init -lockfile=readonly
    - terraform plan -out=tfplan
    - terraform apply -auto-approve tfplan
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
  resource_group: production

resource_group: production is the important line. It serialises jobs so two pipelines cannot apply concurrently, which is what produces most of these.

GitHub Actions:

concurrency:
  group: terraform-production
  cancel-in-progress: false

cancel-in-progress: false matters. Cancelling an apply midway leaves a lock and possibly partial changes.

If you need review between plan and apply

Keeping the plan artifact is legitimate; the risk is the gap. Reduce it:

  • Keep the plan short-lived. A plan approved the next morning is almost certainly stale.
  • Ensure state locking is on. Locking prevents concurrent writes, so the only way the serial moves is a completed apply, not an interleaved one.
  • Re-plan after approval and diff it against the reviewed plan:
terraform show -json tfplan > reviewed.json
terraform plan -out=tfplan2
terraform show -json tfplan2 > current.json
diff <(jq -S '.resource_changes' reviewed.json) <(jq -S '.resource_changes' current.json)

An empty diff means the fresh plan is equivalent to what was reviewed, so applying it is safe. This gets you review and freshness together.

Confirm locking is actually enabled

terraform {
  backend "s3" {
    bucket       = "acme-tfstate"
    key          = "prod/terraform.tfstate"
    region       = "eu-west-1"
    use_lockfile = true
  }
}

use_lockfile is the S3 native locking introduced in Terraform 1.10, replacing the DynamoDB table. Older configurations still use dynamodb_table and that continues to work.

Without locking, two applies can interleave, which produces worse outcomes than a stale plan: state written by one overwrites the other, and resources become orphaned.

terraform force-unlock <lock-id>

Only when you have confirmed no operation is running. A force-unlock on a live apply is how two writers happen.

"Saved plan does not match configuration"

A related but different error: the plan file was made from different configuration than the one now in the directory. A git pull or a branch switch between plan and apply causes it. Same fix, a fresh plan.

A checklist

  1. terraform state pull and compare the serial.
  2. Versioned backend → list object versions to see who wrote and when.
  3. Always re-plan. Never look for a way to force an old plan.
  4. Keep plan and apply in one job where you can.
  5. Add resource_group (GitLab) or concurrency (GitHub) to serialise applies.
  6. cancel-in-progress: false, since cancelling an apply is worse than queueing.
  7. Need review → re-plan after approval and diff the JSON against the reviewed plan.
  8. Confirm state locking is configured; without it the failure modes are worse.

Frequently Asked Questions

Why can I not force Terraform to apply a stale plan?

Because the plan is a precise description of the changes to make against a specific state, and if that state has moved, the plan's assumptions may no longer hold. Applying it could delete a resource created since, or fail partway through leaving things half-changed. There is deliberately no override flag. The correct response is always to re-plan, which takes seconds and produces a plan that matches reality.

What actually changes the state serial?

Any write. A completed terraform apply is the obvious one, but so are terraform state rm, state mv and import, and a terraform refresh or a plan run with refresh enabled that detects drift. In a team, the most common cause is simply another person or another pipeline run applying between your plan and your apply, which is why serialising applies matters more than anything else you can do about this.

How do I keep manual approval without hitting this?

Re-plan after the approval rather than applying the stored file, and diff the new plan against the reviewed one to confirm nothing meaningful changed. terraform show -json on both plans, compared with jq -S '.resource_changes', gives a reliable equality check. An empty diff means the fresh plan is equivalent to what a human approved, so you get both review and freshness. Keeping the approval window short also helps considerably.

Does state locking prevent this error?

Not entirely, and it prevents much worse things. Locking stops two applies writing state concurrently, so the serial only moves through completed operations rather than interleaved ones. You can still plan, wait, and have someone else apply successfully in between, which leaves your plan stale. What locking prevents is the scenario where two applies both write state and one silently overwrites the other, orphaning resources. Always have it on.

What is the difference between a stale plan and one that does not match the configuration?

A stale plan means the state changed after the plan was created. A plan that does not match the configuration means the .tf files in the working directory are different from those the plan was generated against, typically after a git pull or a branch switch. Both are refused for the same reason, that the plan no longer describes what would happen, and both are resolved by generating a fresh plan from the current configuration and state.

Reference and practice

Learn the underlying concept

Other Terraform errors