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
terraform state pulland compare the serial.- Versioned backend → list object versions to see who wrote and when.
- Always re-plan. Never look for a way to force an old plan.
- Keep plan and apply in one job where you can.
- Add
resource_group(GitLab) orconcurrency(GitHub) to serialise applies. cancel-in-progress: false, since cancelling an apply is worse than queueing.- Need review → re-plan after approval and diff the JSON against the reviewed plan.
- 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.