Reading a Plan Correctly
The summary said 4 to add, 2 to change, 0 to destroy. The apply took the production database offline for eleven minutes. Nothing in that summary was wrong.
The Problem at Scale
A plan is read from the bottom, and the bottom is the least informative part of it. 2 to change covers a tag edit and a change that rebuilds a database, and it covers a replacement the plan could not predict at all.
Reviewing a plan is a skill with a method, and almost nobody is taught it. The method matters more as the estate grows, because the plans get longer, the fraction of them anybody reads carefully falls, and the consequential lines are a small minority buried in noise.
The line to stop on is must be replaced, not the summary. A replacement is a destroy and a create of something that exists right now, and for stateful resources it is an outage and sometimes data loss. Every replacement in a plan should be explained by a specific annotated attribute, and if you cannot say which attribute and why, you are not finished reading.
How It Works
The symbols
+ create
- destroy
~ update in place, no interruption
-/+ DESTROY then create <- outage. read this one.
+/- create then destroy <- create_before_destroy
<= read a data source
The distinction between -/+ and +/- is the order, and the order is whether there is a gap. -/+ removes the thing and then makes a new one, so between those two moments it does not exist. +/- makes the replacement first, which is the create_before_destroy lifecycle and the subject of Module 4.
Terraform also annotates the reason, and this is the part to read:
# aws_db_instance.primary must be replaced
-/+ resource "aws_db_instance" "primary" {
~ engine_version = "14.7" -> "15.3" # forces replacement
~ id = "orders-prod-db" -> (known after apply)
...
}
One attribute caused it, the plan names it, and that line is the entire decision.
Known after apply, and the replacement you may not need
The most misread element of any plan, and it fails in the opposite direction to the one people assume.
(known after apply) means Terraform cannot compute the value yet, because it comes from a resource that does not exist. Unknown values propagate: anything derived from an unknown is also unknown.
Now the part that matters. When an unknown lands on an attribute that forces replacement, Terraform compares the recorded value against the planned one, and an unknown is not equal to anything, including the value already there. So it concludes the attribute is changing and marks the resource for replacement:
an unknown value feeds a replacement forcing attribute
-> unknown != the current value, by definition
-> the plan says "must be replaced"
-> and the value may turn out to be identical
Terraform is being conservative, which is the right default: it would rather predict a replacement that does not turn out to be needed than fail to predict one that is. The consequence for you is that a plan can propose destroying a database because of an attribute that was never going to change.
The reading rule is therefore not to trust a replacement blindly in either direction. When a plan proposes a replacement driven by an unknown, the question is what that unknown resolves from, and whether it genuinely varies.
There is a second, unrelated hazard in the same area: providers can mark an attribute as replacement forcing without the plan displaying that attribute at all, so a resource can be marked must be replaced with no visible forcing line. When you cannot find the cause in the output, that is the situation, and the answer is in the provider rather than in the plan.
What a plan does not tell you
Six things, and the first is the one that gets people.
Duration. A tag change and a forty minute managed database replacement are one line each.
Downtime. Related and not identical. Terraform reports the operations, not their effect on availability.
Whether it will succeed. A plan is a proposal, not a validation. Quotas, permissions, dependencies and provider errors are all apply time.
Cost. Nothing in a plan is priced.
What a provisioner does. A local-exec block runs a script; the plan shows that it will run and knows nothing about what it does.
What a module you did not read does. A plan shows resources; the reasons live in code you may never have opened.
A method
In this order, and deliberately not starting where the eye lands.
Search for must be replaced first. Every hit needs a named cause and a conscious decision.
Read every # forces replacement annotation. That is the causal explanation and it is one line.
Count the destroys and account for each. A destroy you cannot explain is the highest severity thing in the file.
Look for unknowns feeding replacement forcing attributes. These are the changes the plan may be understating.
Check for resources you did not expect to appear. A module bump or a provider upgrade will move things you were not thinking about.
Read the summary last, as a check on what you already understood rather than as the thing you understood.
The machine readable form
For anything beyond one person reading one plan, produce JSON and query it. This is what Module 7 builds policy on.
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
And the exit code, which is how a pipeline learns whether anything changed at all:
terraform plan -detailed-exitcode
0 no changes
1 error
2 changes present
Exit code 2 is the drift signal in Module 3 and the gate in Module 8.
Building and Operating It
Find the consequential lines mechanically rather than by reading.
terraform plan -out=tfplan >/dev/null
terraform show -json tfplan > plan.json
# Replacements, with the resource address. This is the review.
jq -r '.resource_changes[]
| select(.change.actions | index("delete") and index("create"))
| "REPLACE \(.address)"' plan.json
# Destroys with no accompanying create. These should be rare and
# every one should be explained.
jq -r '.resource_changes[]
| select(.change.actions == ["delete"])
| "DESTROY \(.address)"' plan.json
Find the attribute that caused each replacement.
# replace_paths names the attribute that forced it, which is the
# one line that explains the whole change.
jq -r '.resource_changes[]
| select(.change.replace_paths != null)
| "\(.address): forced by \(.change.replace_paths | flatten | join(", "))"' \
plan.json
Flag replacements you may not need.
# Replacements driven by an unknown value. Terraform plans these
# conservatively, so some of them are replacements of a resource
# whose forcing attribute would not actually have changed.
jq -r '.resource_changes[]
| select(.change.after_unknown != {} and .change.replace_paths != null)
| "UNKNOWN-DRIVEN REPLACE \(.address)"' plan.json
And make destroys impossible to miss in review.
# A pull request comment that leads with what matters, rather than a
# 900 line plan whose summary is at the bottom. Module 8 builds the
# pipeline; this is the part that changes review quality.
- run: |
replaces=$(jq -r '[.resource_changes[]
| select(.change.actions|index("delete") and index("create"))] | length' plan.json)
destroys=$(jq -r '[.resource_changes[]
| select(.change.actions == ["delete"])] | length' plan.json)
if [ "$replaces" -gt 0 ] || [ "$destroys" -gt 0 ]; then
echo "::warning::$replaces replacements, $destroys destroys in this plan"
fi
A team reviewed a plan summarised as 4 to add, 2 to change, 0 to destroy and approved it. One of the two changes was a managed database engine version bump, which is a replacement forcing attribute on that provider, and the plan had said so on an annotated line roughly four hundred lines above the summary. The database was destroyed and recreated from a snapshot, which took eleven minutes and lost the writes since the snapshot. The summary was accurate, because a replacement counts in the change column rather than the destroy column. The team had been reading summaries for two years and had never encountered a replacement, so nothing had ever taught them that the summary does not distinguish the two.
Tradeoffs and Decision Framework
| Symbol | Meaning | Stop and think? |
|---|---|---|
+ | Create | Only if unexpected |
~ | Update in place | Rarely |
- | Destroy | Always. Account for every one |
-/+ | Destroy then create | Always. This is an outage |
+/- | Create then destroy | Yes, but there is no gap |
(known after apply) | Value not yet computable | Yes, if it feeds a replacement |
Three questions on any plan. What is being replaced and which attribute forced it, since that is where the outages are. Is anything being destroyed that you cannot explain, because that is the highest severity line in the file. And are there unknowns feeding replacement forcing attributes, as that is where the apply can exceed the plan.
Default: read for replacements and destroys before anything else, require a named forcing attribute for each, render plans to JSON and surface replacement and destroy counts at the top of the review, and treat the summary as a final check rather than as the plan.
Failure Modes and Common Mistakes
Reading the summary. It does not distinguish a tag edit from a database rebuild, because a replacement counts as a change.
Skimming a long plan. The consequential lines are a small minority, so find them mechanically.
Ignoring known after apply. An unknown on a replacement forcing attribute produces a planned replacement that may be unnecessary.
Assuming a plan validates. Quotas, permissions and provider errors are all apply time.
Assuming a plan is priced or timed. It is neither.
Approving a replacement without naming the attribute. The annotation is one line and it is the whole decision.
Reviewing plans only by eye. JSON output and three jq queries surface everything that matters in seconds.
A plan summary reads: Plan: 4 to add, 2 to change, 0 to destroy. Applying it takes a production database offline for eleven minutes. How is that consistent with the summary?
Walk me through how you review a Terraform plan for production. What do you look at first?