Declarative infrastructure means writing down what should exist and letting a tool compute how to get there. An imperative script issues a sequence of API calls and has no idea what already exists; run it twice and you get two of everything, or a failure halfway through. A declarative configuration describes the end state, and the tool owns the difference.
The value is not the file, though. It is the diff the file produces and the state that makes the diff possible. State is the mapping from a configuration address to a real resource, and it is the only thing that knows what you own. Nearly everything that goes wrong with infrastructure as code goes wrong at that mapping.
Plan is the diff, apply is the change
Terraform reads the real resources back into state, compares that against the configuration, and prints the difference as an execution plan: what will be created, changed or deleted. Reviewing the plan before applying is what makes approval real, because the plan is a precise statement about what the provider API will do rather than a description of intent.
Re-running the loop is the other half of the promise. After the first apply, state records both resources and their attributes, and a second plan prints no changes at all. That idempotence is what makes the workflow re-runnable, and it is worth measuring rather than assuming.
State is the mapping, not the configuration
State records which real resource each address owns. Without it the tool cannot tell “create” from “adopt”, and the failure is not a confusing error but orphaned infrastructure. Removing the state file while the resources keep running makes the next plan propose to create both of them; the apply then fails with an already-exists error, and the real resources are unmanaged — still running, still billed, tracked by nothing. Recovery is terraform import for each address, by hand.
That is why state belongs in a remote backend with versioning, and why it must be locked. Two applies that both read serial 3 will both write serial 4, and the resource one of them created disappears from state entirely; with the lock held, the second apply waits, re-reads, and writes serial 5. The lock is what makes read-decide-write safe rather than last-writer-wins.
Drift is a diff, not an error
Someone changes an attribute in the provider console. Refresh reads reality back into state, the plan shows an update, and the next apply reverts it. That is correct behaviour for the tool and a social problem for the team: the console change vanishes with no audit trail, usually during an unrelated deploy, and nobody connects the two events.
The sharper edge is removal. Deleting a resource block from the configuration is not a no-op: the plan contains a destroy, and applying it deletes the real resource. A refactor that moves a block into another module produces the same destroy-and-create pair, which is how a database gets deleted by a change that looked like tidying.
When it breaks
The classic failure is drift followed by a silent revert. An engineer raises an instance type in the console during an incident, the next apply from CI puts it back, and the change gets attributed to a mystery rather than to the tool. Diagnose by reading the plan’s update lines before applying, and by alerting on plan exit code 2 in CI instead of auto-applying.
The second failure is losing state, and its blast radius is the whole estate. A deleted, corrupted or unversioned state file orphans every resource it mapped: those resources keep costing money and stop being manageable. Keep state in a versioned remote backend with locking enabled, and treat any plan containing destroy lines as needing a second pair of eyes.
Check it quickly
Read the state mapping and ask the plan whether reality matches:
terraform state list
terraform plan -detailed-exitcode; echo "plan exit: $?"
terraform state pull | jq -r '"serial \(.serial), \(.resources | length) resources"'
The first lists every address the state claims to own. The second exits 0 when reality already matches the configuration, 2 when there is a diff, and 1 on an error, so a 2 in CI means a human must read the plan. A wrong result looks like a plan that exits 2 forever, destroy lines you did not author, or a state list missing a resource you know is running.
Sources
- Terraform: state, what state records and why it is the source of truth for the mapping, checked 2026-09-16.
- Terraform: plan command, the refresh-then-diff cycle and
-detailed-exitcode, checked 2026-09-16. - Terraform: remote state, shared storage for the mapping, checked 2026-09-16.
- Terraform: state locking, why concurrent applies must not race, checked 2026-09-16.