| 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.
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 staleEach scenario uses its own directory under ~/terraform-labs/terraform-saved-plan-is-stale/. Examples use built-in terraform_data only.
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:
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.
mkdir -p ~/terraform-labs/terraform-saved-plan-is-stale/errors/stale-plan
cd ~/terraform-labs/terraform-saved-plan-is-stale/errors/stale-planStart with one resource:
cat > versions.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
EOFThe primary resource definition goes in main.tf:
cat > main.tf <<'EOF'
variable "marker" {
type = string
default = "initial"
}
resource "terraform_data" "primary" {
input = var.marker
}
EOFInitialize and create the baseline in state:
terraform init -input=falseCreate the baseline resource in state:
terraform apply -auto-approve -input=falseRecord the state serial and lineage after the first apply:
terraform state pull | jq '{serial, lineage}'Add a second resource to the configuration and save a plan — do not apply yet:
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"
}
EOFCapture the intended change into a plan file:
terraform plan -no-color -input=false -out=tfplanPlan: 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:
terraform state pull | jq '{serial, lineage}'Before applying tfplan, simulate another pipeline run that changes state — revert the secondary block and bump the marker:
cat > main.tf <<'EOF'
variable "marker" {
type = string
default = "state-changed"
}
resource "terraform_data" "primary" {
input = var.marker
}
EOFApply that unrelated change so the state serial advances:
terraform apply -auto-approve -input=falseThe serial should increase while the lineage stays the same:
terraform state pull | jq '{serial, lineage}'Now try to apply the saved plan from the earlier serial:
terraform apply tfplanError: 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/:
mkdir -p ~/terraform-labs/terraform-saved-plan-is-stale/errors/state-mismatch
cd ~/terraform-labs/terraform-saved-plan-is-stale/errors/state-mismatchBaseline configuration:
cat > versions.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
EOF
cat > main.tf <<'EOF'
resource "terraform_data" "primary" {
input = "baseline"
}
EOFInitialize, apply, and save a plan:
terraform init -input=false
terraform apply -auto-approve -input=false
terraform plan -no-color -input=false -out=tfplanBootstrap a separate state file with a different lineage in a sibling directory:
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-mismatchCompare lineages — they must differ:
terraform state pull | jq '{serial, lineage}'
jq '{serial, lineage}' foreign.tfstateterraform 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:
terraform state push -force foreign.tfstateApplying the original plan now fails differently:
terraform apply tfplanError: 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:
cd ~/terraform-labs/terraform-saved-plan-is-stale/errors/stale-planSet configuration to the desired end state — primary with the updated marker plus the secondary resource:
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"
}
EOFDiscard the stale artifact and plan against the current state:
rm -f tfplan
terraform plan -no-color -input=false -out=tfplanPlan: 1 to add, 0 to change, 0 to destroy.Review the new file, then apply it:
terraform apply tfplanApply 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 (
concurrencygroup in GitHub Actions, equivalent controls elsewhere) - Keep plan and apply on the same commit SHA with minimal delay between stages
- Regenerate
tfplanif 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:
mkdir -p ~/terraform-labs/terraform-saved-plan-is-stale/verify
cd ~/terraform-labs/terraform-saved-plan-is-stale/verifyWrite a minimal configuration:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
resource "terraform_data" "verify" {
input = "clean-plan-apply"
}
EOFInitialize the verify workspace:
terraform init -input=falseConfiguration should validate:
terraform validate -no-colorSuccess! The configuration is valid.Save and apply in one uninterrupted sequence with no intervening operations:
terraform plan -no-color -input=false -out=tfplanApply immediately while state serial is unchanged:
terraform apply tfplanApply complete! Resources: 1 added, 0 changed, 0 destroyed.Destroy lab resources when finished:
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 || trueDiagnostic 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
- terraform plan command — HashiCorp Developer
- terraform apply command — HashiCorp Developer
- State storage and locking — HashiCorp Developer
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.

