← Back to Terraform Plan Blast Radius Check
Every attribute in a Terraform provider's schema can be marked ForceNew (the modern plugin framework calls the same thing RequiresReplace). The flag means one thing: the cloud API behind this resource offers no way to change this field on an existing object. So when that field's value moves, Terraform has exactly one route from the current state to your desired state — destroy the object and create a new one. It reports this as must be replaced, and it is not a bug or a provider mistake. It is the honest consequence of an API that has no update path for that field.
For a virtual machine, a replacement is an outage you can plan around. For a database, a disk, a bucket, a queue, or a KMS key, the new object is empty, and nothing in the plan puts the old contents back. The restore, if a restore is even possible, is a separate thing you do by hand afterwards — assuming a snapshot exists and assuming the retention window has not already expired.
A plan ends with one line:
Plan: 3 to add, 1 to change, 2 to destroy.
A replacement is counted in both the add column and the destroy column. So a plan that recreates one production database and deletes one abandoned log group produces the same trailing line as a plan that creates two scratch resources and deletes two others. The information that separates them exists — it is in the -/+ resource "aws_db_instance" "primary" header hundreds of lines up, next to the # forces replacement comment — and it is in the part of the output that a reviewer skims, a CI log truncates, and a pull-request bot collapses behind a "show more" toggle.
This is a presentation problem, not a data problem. Terraform knows precisely which resources are being replaced and which attributes forced each one. It is simply not the thing it puts at the end.
There is a sharper version of this, and it is worth knowing because no amount of careful reading of the text output fixes it. An attribute a provider marked RequiresReplace whose own value did not change is folded into the (N unchanged attributes hidden) summary — so the plan tells you the resource must be replaced and does not show you the field responsible.
This was reported upstream as hashicorp/terraform#36097, "terraform plan hides unchanged forces replacement attributes", and closed as working as designed. The reporter's example was a TLS certificate being replaced because of ready_for_renewal, an attribute that never appeared anywhere in the plan output.
The structured plan does not hide it. terraform show -json emits, per resource change, a replace_paths array carrying the exact attribute paths that forced the replacement, regardless of whether their values changed — the field was added for precisely this reason, so that consumers of the format can determine which paths caused a replacement.
Everything this tool reports comes out of resource_changes[] in the structured plan:
| Field | What it settles |
|---|---|
change.actions | The action, as an array. ["delete","create"] and ["create","delete"] are both replacements — the second is the create_before_destroy form, which avoids the gap in service but not the loss of anything stored on the old object. |
change.replace_paths | The attribute paths that forced the replacement, including the ones the text output hides. |
action_reason | Why this action rather than an update: replace_because_cannot_update (the ForceNew case), replace_because_tainted, replace_by_request (you passed -replace=), delete_because_no_resource_config, delete_because_count_index, and others. Terraform documents these as display hints whose set can change, and tells consumers to expect codes they do not recognise — so this tool prints an unknown code rather than dropping it. |
A replacement can legitimately have no replace_paths at all: a tainted resource and an explicit -replace=ADDRESS are replaced for reasons that are not any attribute. In those cases action_reason is the whole answer, which is why the report shows both columns.
The destroy count in a plan includes the destroy half of every replacement. What a reviewer usually wants to know is narrower: what is going away and not coming back? That is a resource whose actions are exactly ["delete"] and which nothing else in the plan re-creates at the same address.
The qualifier matters. A create_before_destroy replacement of a deposed object arrives in the plan as two separate entries at the same address — one ["create"], and one ["delete"] carrying a deposed key. Counting that delete as a resource going away would be a false alarm on a perfectly ordinary rolling replacement, and a list that cries wolf is a list nobody reads by the third week.
prevent_destroy stops helpinglifecycle { prevent_destroy = true } is the right backstop and you should use it on anything that holds data. It is not a substitute for reading the plan, for three reasons: it has to have been added to the resource in advance, so it does nothing for the resource somebody forgot; it fails the whole apply rather than telling you which attribute to change; and it says nothing at review time, which is the moment a replacement is cheapest to catch.
The gap is not hypothetical. GitLab's own infrastructure team published a corrective action — "Improve safety around Terraform destroy operations (disks)" — after an accidental deletion of three recently created Gitaly nodes cost roughly 30 minutes of data loss on each. The action they asked for is exactly the missing half: an apply should fail if multiple critical resources containing data (disks) are being deleted. That is a check on the plan, before the apply, on resources identified by what they hold.
This is not a policy engine like Sentinel, OPA, or Conftest, and it is not a cost estimator. It writes no rules for you, evaluates no expressions, and makes no judgement about whether a change should be allowed — it reads exactly one file, the structured plan JSON you already have from your own terraform show -json, and reports what that file says is being destroyed and replaced. It makes no claim about your state file, your provider's source code, or whether a resource it does not flag is safe to lose.
ForceNew is a flag a provider author sets on a schema attribute to say that the underlying cloud API offers no way to change that field on an existing object. When such an attribute changes, Terraform's only route to your desired state is to destroy the object and create a new one. For a virtual machine that is an outage; for a database, a disk, or a bucket it is the data as well, because the new object is empty and nothing in the plan restores it.
Because an attribute a provider marked RequiresReplace whose own value did not change is folded into the (N unchanged attributes hidden) summary, so the plan can say a resource must be replaced without showing which field caused it. This was reported upstream as hashicorp/terraform#36097 and closed as working as designed. The structured plan JSON does not hide it — change.replace_paths carries the exact attribute paths whether or not their values changed.
It is the right backstop and it is not a substitute for reading the plan. prevent_destroy makes the apply fail rather than proceed, which is valuable, but it has to have been added to the resource in advance, it stops the whole apply rather than telling you what to change, and it does nothing for a resource somebody forgot to annotate. A public GitLab corrective action after an incident that lost roughly 30 minutes of data on three nodes asked for exactly the missing half: an apply should fail if multiple critical resources containing data are being deleted.
Because the text output is designed for a human reading it top to bottom at the moment it is produced, and the two facts that matter most — which resources are replaced and why — are scattered through it and summarised at the end in columns that conflate a replacement with a creation and a deletion. The structured plan is the same information as data: one array of resource changes, each with an actions list, a replace_paths list, and an action_reason code. Anything can read it, including a CI gate that runs before the apply step.