Fix Terraform "Saved Plan Is Stale" Error

Tested on Ubuntu 26.04 LTS (Resolute Raccoon)
Package terraform 1.15.8-1
Applies to Any host with Terraform installed
Lab environment Single Ubuntu VM with Terraform — Terraform lab environment on Ubuntu
Privilege Normal user
Scope Troubleshooting Terraform Saved plan is stale and Saved plan does not match the given state — reproduce with terraform plan -out, state serial bumps, stale versus lineage mismatch, fresh plan regeneration, CI/CD race causes, locking and concurrency prevention, and validate-plan-apply verification. Does not cover full pipeline design or configuration snapshot drift at apply time beyond a short comparison.
Related guides terraform plan
Terraform CI/CD
Terraform state locking
terraform apply
Terraform troubleshooting

Saved plan is stale means Terraform will not apply a plan file because state moved on after you saved that plan. A saved plan is frozen against a particular state snapshot — same lineage, specific serial — plus the configuration context from plan time.

text
terraform plan -out=tfplan   →  plan tied to state serial N
another apply succeeds       →  state serial becomes N+1 (or higher)
terraform apply tfplan       →  Error: Saved plan is stale

Each scenario uses its own directory under ~/terraform-labs/terraform-saved-plan-is-stale/. Examples use built-in terraform_data only.

NOTE
A saved plan does not re-read your edited main.tf at apply time. Configuration drift is a separate failure mode — see Terraform CI/CD for why plan and apply must share the same commit.

What a saved plan binds to

terraform plan -out=tfplan writes a binary plan file that records:

  • The proposed create, update, and destroy actions
  • Variable values and provider configuration from plan time
  • The state lineage and serial the plan was computed against

When you run terraform apply tfplan, Terraform checks that the live state still matches that snapshot. If another operation applied in between, the serial advanced and the plan is stale.

State metadata lives in terraform.tfstate (or your remote backend). You can inspect serial and lineage without applying:

bash
terraform state pull | jq '{serial, lineage}'

The error message does not print serial numbers — you compare behavior: same lineage with a bumped serial is stale; a different lineage is does not match.


Reproduce Saved plan is stale

The lab applies an initial resource, saves a plan for a second resource, then applies a different change that advances state without touching the saved file.

bash
mkdir -p ~/terraform-labs/terraform-saved-plan-is-stale/errors/stale-plan
cd ~/terraform-labs/terraform-saved-plan-is-stale/errors/stale-plan

Start with one resource:

bash
cat > versions.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}
EOF

The primary resource definition goes in main.tf:

bash
cat > main.tf <<'EOF'
variable "marker" {
  type    = string
  default = "initial"
}

resource "terraform_data" "primary" {
  input = var.marker
}
EOF

Initialize and create the baseline in state:

bash
terraform init -input=false

Create the baseline resource in state:

bash
terraform apply -auto-approve -input=false

Record the state serial and lineage after the first apply:

bash
terraform state pull | jq '{serial, lineage}'

Add a second resource to the configuration and save a plan — do not apply yet:

bash
cat > main.tf <<'EOF'
variable "marker" {
  type    = string
  default = "initial"
}

resource "terraform_data" "primary" {
  input = var.marker
}

resource "terraform_data" "secondary" {
  input = "from-saved-plan"
}
EOF

Capture the intended change into a plan file:

bash
terraform plan -no-color -input=false -out=tfplan
output
Plan: 1 to add, 0 to change, 0 to destroy.

  # terraform_data.secondary will be created
  + resource "terraform_data" "secondary" {
      + input = "from-saved-plan"
    }

Confirm the serial is unchanged — saving a plan does not write state:

bash
terraform state pull | jq '{serial, lineage}'

Before applying tfplan, simulate another pipeline run that changes state — revert the secondary block and bump the marker:

bash
cat > main.tf <<'EOF'
variable "marker" {
  type    = string
  default = "state-changed"
}

resource "terraform_data" "primary" {
  input = var.marker
}
EOF

Apply that unrelated change so the state serial advances:

bash
terraform apply -auto-approve -input=false

The serial should increase while the lineage stays the same:

bash
terraform state pull | jq '{serial, lineage}'

Now try to apply the saved plan from the earlier serial:

bash
terraform apply tfplan
output
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.

Terraform exits before making changes. Your exact serial values may differ. What matters is that the lineage remains the same while the serial after the intervening state write is higher than the serial the saved plan was based on.

On Terraform 1.15.8, pass the plan file as the only argument — flags after the filename trigger Too many command line arguments. Use terraform apply -input=false tfplan if you need flags.


Stale plan versus state does not match

Terraform uses two different errors when the plan file and live state disagree:

Error Meaning Typical cause
Saved plan is stale Same state lineage, higher serial Another apply, destroy, or state mutation after plan -out
Saved plan does not match the given state Different state lineage state push, restore from backup, workspace switch, wrong backend

Reproduce the lineage mismatch in errors/state-mismatch/:

bash
mkdir -p ~/terraform-labs/terraform-saved-plan-is-stale/errors/state-mismatch
cd ~/terraform-labs/terraform-saved-plan-is-stale/errors/state-mismatch

Baseline configuration:

bash
cat > versions.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}
EOF

cat > main.tf <<'EOF'
resource "terraform_data" "primary" {
  input = "baseline"
}
EOF

Initialize, apply, and save a plan:

bash
terraform init -input=false
terraform apply -auto-approve -input=false
terraform plan -no-color -input=false -out=tfplan

Bootstrap a separate state file with a different lineage in a sibling directory:

bash
mkdir -p ../foreign-bootstrap
cd ../foreign-bootstrap

cat > main.tf <<'EOF'
resource "terraform_data" "foreign" {
  input = "foreign-lineage"
}
EOF

terraform init -input=false
terraform apply -auto-approve -input=false
cp terraform.tfstate ../state-mismatch/foreign.tfstate
cd ../state-mismatch

Compare lineages — they must differ:

bash
terraform state pull | jq '{serial, lineage}'
jq '{serial, lineage}' foreign.tfstate
WARNING
Lab only. terraform state push -force bypasses Terraform's lineage and serial safety checks. Never use it on production state except in a deliberate recovery with backups and a written runbook.

Force-push the foreign snapshot over the workspace state:

bash
terraform state push -force foreign.tfstate

Applying the original plan now fails differently:

bash
terraform apply tfplan
output
Error: Saved plan does not match the given state

The given plan file can not be applied because it was created from a
different state lineage.

Restore the correct state from your backend or backup before replanning — a fresh plan -out against foreign state would plan from the wrong world entirely.


Fix with a fresh plan

Do not edit the stale binary plan or roll state backward just to make an old artifact apply. Stay in the same directory where state advanced and regenerate against the current snapshot:

bash
cd ~/terraform-labs/terraform-saved-plan-is-stale/errors/stale-plan

Set configuration to the desired end state — primary with the updated marker plus the secondary resource:

bash
cat > main.tf <<'EOF'
variable "marker" {
  type    = string
  default = "state-changed"
}

resource "terraform_data" "primary" {
  input = var.marker
}

resource "terraform_data" "secondary" {
  input = "from-saved-plan"
}
EOF

Discard the stale artifact and plan against the current state:

bash
rm -f tfplan
terraform plan -no-color -input=false -out=tfplan
output
Plan: 1 to add, 0 to change, 0 to destroy.

Review the new file, then apply it:

bash
terraform apply tfplan
output
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

The new plan matches today's serial and lineage, so apply succeeds. Only secondary is added because state already contains the updated primary from the intervening apply.


Why this happens in CI/CD

Stale plans appear when plan and apply are separated in time or across jobs:

  • Another workflow run applied to the same workspace first
  • A manual apply or hotfix landed between plan and approval stages
  • Plan and apply jobs target the same remote state without concurrency limits
  • A long approval gate lets state drift before the apply job starts

State locking prevents incompatible Terraform operations from modifying the same state concurrently, but it does not guarantee that a saved plan remains current after its planning operation releases the lock. Another run can apply before that saved plan reaches its own apply stage, making the earlier plan stale. Serialize apply jobs per workspace or use a single plan-and-apply stage on protected branches.


Prevent stale saved plans

  • Enable state locking on your remote backend
  • Limit concurrent apply workflows per workspace (concurrency group in GitHub Actions, equivalent controls elsewhere)
  • Keep plan and apply on the same commit SHA with minimal delay between stages
  • Regenerate tfplan if any other operation might have touched state since the plan job ran
  • For reviewed changes, consider remote run workflows (HCP Terraform) where plan and apply share one run record

Common bad fixes

Bad approach Why it fails
Editing the binary tfplan file Plan format is not meant for hand-editing; integrity checks break
terraform state push old state to match the plan Hides real infrastructure drift; risks destroying or duplicating resources
Disabling state locking Allows concurrent applies; stale plans become data corruption
Applying a stale artifact because "it passed review" Review matched an old serial; live state has moved
Ignoring does not match and forcing apply Plan targets a different lineage — replan after restoring correct state

Verify the workflow

The verify/ directory walks a clean plan-then-apply cycle with no intervening state change:

bash
mkdir -p ~/terraform-labs/terraform-saved-plan-is-stale/verify
cd ~/terraform-labs/terraform-saved-plan-is-stale/verify

Write a minimal configuration:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

resource "terraform_data" "verify" {
  input = "clean-plan-apply"
}
EOF

Initialize the verify workspace:

bash
terraform init -input=false

Configuration should validate:

bash
terraform validate -no-color
output
Success! The configuration is valid.

Save and apply in one uninterrupted sequence with no intervening operations:

bash
terraform plan -no-color -input=false -out=tfplan

Apply immediately while state serial is unchanged:

bash
terraform apply tfplan
output
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Destroy lab resources when finished:

bash
cd ~/terraform-labs/terraform-saved-plan-is-stale/errors/stale-plan && terraform destroy -auto-approve -input=false 2>/dev/null || true
cd ~/terraform-labs/terraform-saved-plan-is-stale/verify && terraform destroy -auto-approve -input=false 2>/dev/null || true

Diagnostic checklist

Symptom Likely cause Fix
Saved plan is stale State serial advanced after plan -out New terraform plan -out=tfplan, review, apply
Saved plan does not match the given state Wrong lineage (restore, push, workspace) Restore correct state; replan from truth
Plan job green, apply job stale Another run applied first Serialize applies; replan on apply stage
Apply succeeds but config ignored Edited files after plan; not a stale error Plan and apply same commit — see CI/CD lesson
Stale after lock file change Different error (Inconsistent dependency lock file) terraform init and new plan — see lock file guide

References


Summary

Saved plan is stale blocks terraform apply tfplan when state changed after the plan was created — same lineage, higher serial. Another apply, destroy, or state update between plan and apply stages triggers it. The fix is always to discard the old artifact, run terraform plan -out=tfplan against current state, review the output, and apply the new file.

Saved plan does not match the given state is the lineage variant — the plan was built from a different state file entirely. Restore the correct state or accept the new lineage, then replan; do not force the old plan.

In CI/CD, stale plans come from pipeline races and approval delays. Locking prevents incompatible concurrent state writes but does not replace regenerating the plan when another run may have applied first. For the separate trap where configuration edits do not invalidate a saved plan, read Terraform CI/CD alongside this guide.


Frequently Asked Questions

1. What does Saved plan is stale mean in Terraform?

Saved plan is stale means the state snapshot changed after Terraform created the saved plan. State has a monotonically increasing serial number; when Terraform writes a newer state version, an older saved plan may no longer be valid.

2. How do I fix a stale Terraform plan file?

Discard the old plan artifact and run terraform plan -out=tfplan again against the current state. Review the new plan, then terraform apply tfplan. Do not try to force the outdated file through.

3. What is the difference between stale and Saved plan does not match the given state?

Stale means the same state lineage advanced — another operation applied after the plan was saved. Does not match means the plan was built from a different state lineage entirely, such as after restoring or pushing foreign state.

4. Does editing main.tf make a saved plan stale?

No. Terraform does not compare your working tree to the saved plan at apply time. Configuration edits can let an old plan apply silently while ignoring your files; only state changes trigger the stale error. Plan and apply from the same commit in CI.

5. How do I prevent stale plans in CI/CD?

Use remote state with locking, serialize apply jobs per workspace, keep plan and apply on the same commit with minimal delay, and regenerate the plan if any other pipeline run may have applied first.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)