Cloud Infrastructure · Infrastructure as Code
What a Plan File Records and Why Applying It Is Not a Re-Plan
How the plan is computed, what it stores beyond the proposed changes, and why the saved artifact can disagree with the world it is applied to.
The plan is the artifact that makes declarative provisioning reviewable. Without it, the only way to know what an apply will do is to read the configuration and reason about every provider’s behaviour, which is not a check anyone performs reliably on a few thousand resources. With it, the tool states the intended operations in advance, and a human or a policy engine can reject them before any API call is made.
The interesting part is not the diff. It is that the plan is a computed object with inputs from three places (the configuration, the state, and a fresh read of the world) and that the apply consumes the computed result rather than recomputing it. Understanding what is frozen at plan time, and what is not, explains nearly every surprising apply failure.
Three inputs and one output
Planning begins by refreshing state against the remote API, producing an observed world. It then evaluates the configuration against variables, locals, and the observed attributes, producing a desired world. The diff between them is the change set. Each resource instance gets one of a small number of actions: create, update in place, destroy, or replace, where replace is expressed as a destroy followed by a create or a create followed by a destroy depending on lifecycle settings.
The plan also carries the dependency graph with it. Ordering was decided during planning, and the apply walks that graph rather than rebuilding it. This is why a plan computed against a stale state can contain an ordering that no longer makes sense: an edge that existed at plan time because a value flowed from A to B may be irrelevant by apply time, and an edge that should exist may be missing.
Values that are not yet known are a special category. If a resource’s identifier is generated at creation, then every attribute derived from that identifier is unknown at plan time. Terraform marks these as unknown and prints them as a placeholder rather than a value. The unknown propagates: a name built from a generated id is unknown, so a resource named from it is unknown, and so on down the chain. A plan with many unknowns is not less correct, it is less informative: the reviewer sees the shape of the change and not its content.
Why the saved plan is the thing that gets applied
terraform plan -out writes the plan to a file, and terraform apply on that file executes exactly what was computed. The reason is consistency: if apply re-planned, it would use a world that had moved since the review, and the approved change set would not be the executed one. Freezing the decision is the point of the two-step workflow.
The file therefore contains more than a list of changes. It contains the configuration snapshot, the prior state, the computed attribute values including unknowns, the provider schemas used, and a checksum over the whole thing. The schema is included because the apply must interpret attribute types the same way the plan did; a provider upgrade between plan and apply can change that interpretation, and the checksum is what detects the mismatch.
Applying a saved plan still performs some checks. The state serial is compared, so a state write between plan and apply invalidates the plan. The configuration is compared against the snapshot, so editing a file between plan and apply invalidates it. The world itself is not re-read, which is the deliberate gap: an object deleted by hand after the plan was made will cause the apply to fail at that resource rather than to silently plan around it.
The window between plan and apply
Everything that changes in that window is invisible. A colleague deletes a security rule, an autoscaler adds instances to a pool the configuration manages, a certificate renews and changes its ARN. The apply proceeds on the old observation, and the outcome depends on the operation:
- A create of an object that now exists fails on a uniqueness constraint, leaving earlier creates in place.
- A destroy of an object that is already gone fails with not-found, or succeeds silently if the provider treats not-found as success, a per-provider decision, and one worth knowing.
- An update computed against an old value overwrites a change that was made deliberately, which is the classic lost update. There is no version check unless the API offers one and the provider uses it.
The window is unavoidable in a distributed system, but its width is a design choice. A plan applied immediately after review has a narrow window; a plan reviewed on Friday and applied on Monday has a wide one. Policy that requires a fresh plan when the configuration or state has changed narrows it further, at the cost of invalidating approvals.
Plan output is not a specification
The textual plan is a rendering, and it omits things. Values marked sensitive are redacted in the display, which means a reviewer cannot see what will be written. Attributes that are unchanged are usually not printed at all, so a resource with one changed field shows one line and hides the other forty. Provider-specific diff rules (a normalizer that treats an empty string and an absent field as equal, a suppression that ignores a server-assigned default) mean the plan reflects the provider’s judgment about what counts as a change, not a byte comparison.
This matters when the plan is used as an approval artifact. Two configurations can produce identical-looking plans and different results, because the provider’s comparison rules differ. The plan is evidence about the operations the tool will attempt, not a proof about the state that will result.
Some tools make the comparison rule explicit and inspectable. Kubernetes server-side apply takes a different approach to the same problem: the client sends a field set with an owner, the API server merges it into the stored object, and conflicts between owners are reported rather than silently resolved. The stored object records which manager owns which fields, so a later reader can tell who set a value. That is a plan-less model (there is no separate artifact to review) which trades the review step for a continuous, conflict-aware merge.
Reading a plan for the failures it does not show
A plan that says “0 to add, 0 to change, 0 to destroy” is the common definition of converged, and it is not the same as correct. It says the configuration and the observation agree. It says nothing about whether the observation matches the intent, because the observation is what the provider chose to return.
Three checks are worth performing on any plan that contains destroys or replacements. The first is whether the count matches expectation: a rename that should move one resource and instead shows a destroy of everything is the signature of an address change, as described in count and for_each instance addressing. The second is whether any replacement targets an object that holds data (a database, a volume, a bucket with objects) since replacement is deletion and the provider may or may not protect it. The third is whether the plan’s unknowns cover the attributes that matter, because an unknown that resolves to a conflicting value at apply time produces a failure halfway through the graph.
The plan is also where drift becomes visible, though not labelled as such. A refresh that finds an attribute changed remotely shows up as a proposed update to restore the configured value. Whether that is remediation or an unwanted overwrite depends on which side is right, and the plan cannot tell. That judgment belongs to detecting and remediating drift, where the same diff is read with a different question in mind.
Cost and the shape of the output
Planning is not free. A refresh reads every managed object, so a configuration with a thousand resources makes at least a thousand API calls before any diff is computed, and providers that must enumerate children or wait for consistency make more. Rate limits turn a large plan into a slow one, and the failure mode is a plan that times out partway through the refresh with a partial observation.
For this reason, tools expose ways to skip the refresh or to target a subset of the graph. Targeting is convenient and hazardous: a plan for one resource assumes everything else is as recorded, so a targeted apply can compute a change that would have been different in the context of the whole configuration. The narrower the target, the more the plan depends on the accuracy of the state rather than on the world.
The output format is a separate concern from the computation. A human-readable rendering is for reading; a JSON rendering is for machines, and it is what policy engines consume to answer questions like whether any proposed change opens a network path to the internet. Both are derived from the same computed plan, and neither is the plan itself.
References
- Terraform Language: Plan — HashiCorp
- Terraform CLI Documentation: terraform plan — HashiCorp
- Kubernetes Documentation: Server-Side Apply — Kubernetes project