DevOps · Platform Automation
Saved Plans, State Serial, and Why Apply Must Be Pinned
A saved plan is a reviewable artifact tied to one state snapshot. What it records, why apply rejects a stale one, and where the guarantee stops.
The split between planning and applying is the part of infrastructure tooling that makes review possible. A plan is a prediction: given this configuration and this recorded state, these resources will be created, these updated in place, these destroyed and recreated. A human or a policy reads the prediction and decides whether to proceed. That workflow only means anything if the thing applied is the thing that was read.
Saving the plan to a file is what enforces that. The file is a binary artifact, opaque by design, produced by terraform plan -out and consumed by terraform apply with the path as its only argument. Passing the file changes apply’s behaviour fundamentally: it does not compute a new plan. It executes the recorded one.
What the plan file actually holds
Four things, all of which matter for the guarantee.
The proposed operations — the ordered set of create, update, replace, and delete actions per resource instance, including the attribute values that will be sent. This is the part a reviewer sees rendered as a diff.
The prior state snapshot, with its lineage identifier and serial number. The serial increments on every state write, and the lineage is generated once when the state is created and survives moves between backends. Together they identify the exact state the plan was computed against.
The input values — variable values, including those supplied on the command line or through a file, and the resolved module versions. Apply does not re-prompt for them, which is why a plan built in one job can be applied in another without the second job knowing the variables.
The configuration it was built from, in enough detail to execute without re-reading the working directory. This is why a plan applied from a directory whose files have since changed still does what it said.
Because the artifact is a serialised internal representation, it is coupled to the version of the tool that wrote it. A plan written by one release is not reliably readable by another, and the failure is not always clean — the documented guidance is that plans are for a single run, not for archival. Storing plans as build artifacts for a week is reasonable; treating them as a record of what happened is not, because the state and the provider’s API are the record.
Why apply refuses a plan it did not compute
If the state has changed since the plan was written, applying it would mean applying a diff computed against a state that no longer exists. The tool detects this by comparing the lineage and serial recorded in the plan against the current state, and refuses when they differ.
The refusal is the correct behaviour, and the reason is not pedantry about version numbers. The plan’s actions encode decisions that depend on the prior state in ways the rendered diff does not show. Whether a changed attribute can be updated in place or forces replacement depends on the provider’s schema and on the prior value. Whether a resource is created or imported depends on whether the state already has it. A resource that was deleted out of band between plan and apply will be recreated by a fresh plan and updated by the saved one, and the two produce different results from the same configuration.
A re-plan at apply time would also break the review contract in a subtler way: the reviewer approved a diff, and a fresh plan may contain additional changes — drift that appeared in the interval — that nobody approved. Pinning the plan converts “apply what the configuration says now” into “apply what was reviewed”, which is the property that lets an approval be meaningful.
The cost is operational. Any concurrent write to the state invalidates every outstanding plan, so a platform that runs plans on pull requests and applies on merge will see stale-plan failures whenever two changes touch the same state. The usual answers are to scope state per service so the collision surface is small, to re-plan on merge rather than reusing the pull-request plan, and to accept that the reviewed artifact and the applied artifact are then different objects — which is a real weakening of the guarantee, not a detail.
Locking protects the state, not the cloud
State locking exists because two applies writing the same state file concurrently produce a state that describes neither reality. The lock is taken before the state is read for writing, records an identifier, and is released at the end. A second process that cannot acquire it waits or fails.
The lock is held against the state backend, so it serialises operations that use that backend. It does not serialise anything against the provider’s API. Two configurations with separate state files can manage the same cloud resource, and neither lock will notice; the second apply overwrites the first’s changes because from its point of view the resource’s prior attributes are whatever its own state recorded. This is the failure mode that makes state boundaries a design decision rather than an accident: a resource must be owned by exactly one state, and the ownership must be visible in the platform’s model, not inferred from naming.
Locking also fails in ways that require human intervention. A process killed while holding a lock leaves the lock in place; most backends support a force-unlock operation, and using it while the original process is still running is how two writers end up in the same state. The safe procedure is to confirm the holder is gone, which in a CI system means confirming the job is not merely slow.
Refresh, drift, and the plan that is already wrong
A plan begins by refreshing: reading the current attributes of every managed resource from the provider and updating the state to match. Only then is the configuration compared. Without the refresh, the plan compares configuration against a possibly stale record and reports no changes for resources that were modified outside the tool.
This is the mechanism behind drift detection: run a plan with refresh and no intention to apply, and the diff is the drift. -refresh-only narrows this further, producing a plan whose only actions are state updates, which is useful when the correct response to drift is to accept the new reality rather than revert it.
The important limitation is that the plan’s view of the world is a point-in-time read, taken resource by resource, without a transaction across the provider. A resource created by someone else after its section of the refresh has no effect on the plan. A resource deleted between the refresh and the apply produces an error partway through. The plan is a claim about a moment, and the apply happens later; the gap is bounded by the tool’s own consistency checks on state, not by the provider’s.
-detailed-exitcode makes the plan usable as a check in a pipeline: exit status 0 means no changes, 2 means changes are pending, 1 means an error. That three-way distinction is what lets a scheduled job report drift without treating “there are changes” as a failure.
The plan as a policy input
The rendered diff is for humans; the plan file can also be rendered as JSON with terraform show -json, which exposes the resource changes in a structured form. That form is what a policy engine can evaluate before the apply runs: which resource types are being deleted, whether any change replaces a stateful resource, whether a public network range is being opened, whether the resource is tagged with an owner.
Evaluating policy against the plan rather than against the configuration has a specific advantage: the plan contains the resolved values, after variables, defaults, and module composition, and it contains the action, not just the desired end state. “This change destroys and recreates the database” is visible in the plan and invisible in a diff of the configuration files. The trade-off is that the plan is derived from state, so a policy evaluated over it is evaluating a prediction — the same prediction that will be applied only if the state has not moved. The enforcement point that does not have this weakness is admission, covered in what an admission webhook can decide, because it sees the object at the moment it is written rather than a plan for writing it.
Partial applies and the state nobody planned for
An apply that fails halfway leaves the resources it already created in the state and the rest absent. The saved plan is consumed; re-running apply with the same file is not the recovery path. The recovery is a new plan, which will propose creating what is missing and will show any resources that were created but not recorded — because the failure happened before the state write, which is exactly the window the state file cannot cover.
That window is the reason a failed apply must be followed by a fresh plan and a read of the resulting diff, not by a retry. It is also the reason -target is a diagnostic tool rather than a deployment strategy: applying a subset of the configuration leaves the state describing a partially realised configuration, and the next full plan will propose the remainder, in an order that was never reviewed.
The artefacts that survive all of this are the state and the provider’s own audit log. A platform that provisions through a shared pipeline should be able to answer, for any resource, which state file owns it and which apply created it — a question that the saved plan cannot answer once it has been consumed, and that the state’s serial and the audit trail can.
References
- Terraform Documentation: terraform plan command — HashiCorp
- Terraform Documentation: State Locking — HashiCorp
- Terraform Documentation: State — HashiCorp