State¶
Someone runs terraform apply from a laptop at the exact moment CI runs it from a pipeline. With no lock
to stop them, both write to the same state file and whichever finishes last silently wins, overwriting the
other's changes. The course page introduces the S3 backend and the state-operations commands that guard
against this in passing; this page slows down on why state exists at all, how to read a plan's
symbols, the two locking mechanisms you'll meet in the wild, and the three situations — bringing an
existing resource under management, renaming one in code, and catching drift — where state stops being
invisible plumbing and becomes something you have to reason about directly.
TL;DR — in 30 seconds:
- State is the third file that maps every config address to the real-world ID Terraform gave it — without it, plan can't tell "already exists" from "brand new."
- Read the plan's four symbols before applying:
+create,~update in place,-destroy,-/+destroy-and-recreate — the one to slow down for is-/+, since it means identity changes, not just an attribute. importbrings an existing resource under management without changing it;movedrenames an address without triggering a destroy-and-recreate — two different jobs, easy to reach for the wrong one.
1. Why Terraform needs a file to tell it what it already made¶
Terraform's config describes desired infrastructure; the cloud holds the actual infrastructure.
Neither side, by itself, tells Terraform which cloud objects correspond to which config blocks. State
is the third file that closes that loop: a JSON record mapping every resource and module address in
your config to the real-world ID it produced (an instance ID, a bucket ARN, a VPC ID), plus a cached copy
of that resource's last-known attributes.
Without state, terraform plan would have no way to tell "this aws_instance.web block already exists as
i-0abc123" from "this is a brand-new block, create it" — every plan would be a guess. State is what
turns "here's the infrastructure I want" into "here's exactly what changed since last time."
flowchart LR
Config["HCL config<br/>(desired)"] --> Plan{"terraform plan"}
State[("State file<br/>(last-known)")] -->|refresh| Plan
Cloud["Real infrastructure<br/>(actual)"] -.->|drift?| Plan
Plan -->|"+ / ~ / − / −/+"| Apply["terraform apply"]
Apply --> Cloud
Apply --> State
2. Reading a plan — the symbol legend¶
Every plan line is prefixed with one symbol. Knowing them cold is the difference between skimming a 100-line plan safely and missing the one destructive line buried in it.
| Symbol | Meaning | What happens |
|---|---|---|
+ |
Create | A new resource, not yet in state, will be made. |
~ |
Update in place | An existing resource changes without being destroyed — most attribute edits. |
- |
Destroy | A resource is removed from config (or state) and will be deleted in the cloud. |
-/+ |
Destroy and recreate | A change to an attribute Terraform can't update in place (it's ForceNew on the provider) — the old resource is destroyed, a new one created with a new ID. |
The one to slow down for is -/+: it means the resource's identity changes, not just an attribute —
a new ID, possibly downtime, possibly data loss if nothing external points at the old one anymore. If a
plan you expected to be a quiet ~ shows -/+ instead, stop and check which attribute forced it before
applying.
3. Remote backends and locking¶
Local state (a terraform.tfstate file on one machine) has no locking and no sharing — two people, or a
human and a CI run, can clobber each other's state or apply from a stale copy. A remote backend fixes
both: state lives in one shared, versioned location, and every plan/apply acquires a lock before
touching it.
For the S3 backend, that lock has come in two forms:
| Locking mechanism | How it works | Status |
|---|---|---|
| DynamoDB lock table | A separate DynamoDB table holds one row per state file as a mutex; Terraform writes/deletes that row around each operation. | The long-standing default — still what you'll find in most existing setups. |
S3-native locking (use_lockfile = true) |
The lock is a small object written alongside the state file in the same S3 bucket — no second service to provision or pay for. | Promoted to GA in Terraform 1.11; the preferred choice for new backends. |
Either way, the effect for a team is the same: a second apply against the same state, started while the
first is still running, blocks (or errors) instead of racing it. Migrating from the DynamoDB table to
native locking is additive — both can be configured side by side while you cut over, then the table is
dropped once nothing depends on it.
Note: the state file itself is plain JSON and can contain sensitive values in clear text — a database password read into an attribute, a generated secret. Encrypting the backend bucket and restricting who can read it isn't optional hardening; it's the only thing standing between that file and a plaintext credential leak.
4. Bringing an existing resource under management — import¶
Not everything managed by Terraform started life in Terraform — a bucket someone clicked into existence
in the console, a resource migrated from another tool. import (block form, 1.5+) tells Terraform
"this config block and that real-world ID are the same thing," without creating or changing anything:
import {
to = aws_s3_bucket.legacy
id = "my-hand-made-bucket"
}
terraform plan then shows what it would take to reconcile — usually a ~ update for any attribute your
config doesn't yet match, since the resource's current real-world configuration becomes the diff
baseline. Write the resource block to match reality first, import, then adjust config deliberately —
importing into a block that doesn't match will otherwise propose a pile of unintended changes on the next
apply.
5. Renaming without recreating — the moved block¶
Refactoring a config — pulling a resource into a module, renaming a resource label for clarity — changes
the resource's address, and Terraform's default reaction to an address it doesn't recognize is to plan
a destroy-and-recreate. The moved block (1.1+) tells it otherwise: same resource, new address, just
update the record.
moved {
from = aws_s3_bucket.logs
to = module.storage.aws_s3_bucket.logs
}
terraform state mv does the identical thing imperatively from the CLI, useful for a one-off fix; moved
is preferred for anything checked into config, because it travels with the refactor and applies for every
teammate's next plan automatically instead of requiring everyone to remember to run a state command.
6. Drift — when the cloud stops matching state¶
Drift is any gap between state's last-known attributes and what's actually deployed — someone
hand-edited a security group rule in the console, an auto-scaling event changed a tag. terraform plan
-refresh-only reconciles state with reality and reports the difference without proposing any other
change, which is exactly the read-only check you want before deciding what to do about it.
Two responses to a drift finding:
- Accept it into config — if the manual change was intentional and should stick, update your HCL to
match, then a normal
applybrings config, state, and cloud back into agreement. - Overwrite it — if the manual change was a mistake, run a normal
apply; Terraform reasserts config over the drifted value.
Scheduling plan -refresh-only on a recurring cadence (nightly, in CI) is the standard way to catch drift
early — silent drift compounds, because the next unrelated apply will fold the drifted attribute back to
config with no warning unless someone already reviewed it.
7. How this connects¶
The S3 bucket and lock backend introduced on the main course page are the mechanism this page goes deeper
on; import and moved are the two tools for keeping state honest when config changes without wanting
the underlying resource to change; drift-checking is the ongoing discipline that keeps state trustworthy
between those refactors.
Common gotchas
- Running local state with no remote backend on a team (or CI) project. Fix: local state has no locking — two applies against the same state can race and silently clobber each other; a remote backend adds both shared storage and a lock.
- Reading a
-/+line as "just an update." Fix: it means Terraform can't update that attribute in place (it'sForceNewon the provider) — the resource is destroyed and recreated with a new ID; stop and check which attribute forced it before applying. - Writing a resource block from scratch and then
import-ing into it. Fix: the resource's current real-world configuration becomes the diff baseline, so a mismatched block plans a pile of unintended changes — write the block to match reality first. - Skipping
plan -refresh-onlyand only noticing drift when an unrelated apply silently folds it back into config. Fix: schedule refresh-only checks (nightly, in CI) so drift is a reviewed decision, not a surprise buried in the next apply.
Check yourself — two people run apply against the same local state file at almost the same moment. What actually happens, and what would prevent it?
Local state has no lock, so whichever apply finishes last silently overwrites the other's state, even if both applies technically succeeded. A remote backend (e.g. S3) with a lock — DynamoDB-table-based or S3-native — makes the second apply block or error instead of racing.
Check yourself — you import a hand-created S3 bucket, but your resource block was written from a template rather than matching the bucket's real settings. What does the next plan show?
A pile of ~ (or worse) changes — the resource's actual current configuration becomes the diff
baseline on import, so anything your block doesn't already match reads as a change Terraform proposes
to make.
You can defend this when you can explain what problem state solves that config alone can't, read a
plan's +/~/-/-/+ symbols without hesitating, name both S3 locking mechanisms and which one is
preferred today, and describe when you'd reach for import versus moved.
Go deeper: the full S3 backend and locking reference (including
use_lockfileand the DynamoDB migration path) is in the Terraform S3 backend docs;importandmovedeach have their own reference in the import block docs and the refactoring guide — this page covers the 80/20 you need for the Do exercise.