Terraform state operations: import, move, and the destroy-by-rename trap
State maps config to real resource IDs and is authoritative: a refactor that renames a resource without a moved block tells Terraform to destroy prod and recreate it empty. Master import, state mv, state rm, and state splitting, and treat state as a secret.
You open a tidy refactor PR: it renames aws_db_instance.db to aws_db_instance.primary — a one-word change for readability, nothing else touched. Review approves it in thirty seconds; the rename “obviously” can’t do anything. CI runs terraform apply on merge. The plan, which nobody opened, says -/+ destroy and then create replacement for the production RDS instance. Terraform sees the old address gone from config and a new address absent from state, so it does exactly what you told it: it deletes the live database and stands up a fresh empty one. Forty seconds later the app is throwing connection errors against an empty schema, the last automated snapshot is six hours old, and you are restoring prod from backup while customers watch a maintenance page. A rename did that — because state, not your config, is the source of truth, and nobody told it the resource simply moved.
State is the mapping, and the mapping is authoritative
You already set up a remote S3 backend with locking in the previous lesson. Now the dangerous part: operating that state without losing data. The state file is a map. For every resource block in your config it stores the binding to a real cloud object — the address aws_db_instance.primary ↔ the actual RDS identifier db-prod-7f3a, plus every attribute and dependency edge. terraform plan is a three-way diff: it compares your config, the state, and reality (refreshed from the live API), then plans the minimum set of API calls to reconcile them.
The trap is that state, not config, decides what each resource is. The reconciliation rule is brutally literal:
- A resource in config but not state → Terraform thinks it is new → CREATE.
- A resource in state but not config → Terraform thinks you removed it → DESTROY.
- A resource in both, with changed immutable attributes → REPLACE (
-/+, destroy then create).
Together these three rules explain why every state disaster starts the same way: someone changes config without updating the mapping. Without rule 2, a delete is indistinguishable from a rename.
So when you rename aws_db_instance.db to .primary in config without telling state, Terraform sees two independent facts: the address .db vanished from config (→ destroy the real DB it still maps to) and .primary is absent from state (→ create a new empty DB). The literal output is two operations, one destroy and one create, on what you meant to be a single resource keeping its identity. The rename was never the problem; failing to move the mapping was.
import, state mv, and the declarative moved block
Three operations keep the mapping honest during change. terraform import adopts an existing, unmanaged resource — one created by hand in the console, or by another tool — into state, so Terraform manages it instead of trying to recreate a duplicate that would collide on a unique name:
# Adopt a hand-created bucket into management — don't let TF recreate it.
terraform import aws_s3_bucket.assets acme-prod-assets
# Now `plan` shows no-op (or only config drift), not a CREATE.terraform state mv renames an address in state without touching the real resource — the imperative fix for the Hook:
terraform state mv aws_db_instance.db aws_db_instance.primary
# State now maps .primary → the live db-prod-7f3a; plan is a no-op.The modern, reviewable form is the declarative moved {} block, committed alongside the rename so the move travels with the PR and runs on apply for everyone, including CI — no out-of-band CLI step that a teammate forgets:
resource "aws_db_instance" "primary" { # renamed from "db"
identifier = "db-prod-7f3a"
# ...unchanged...
}
moved {
from = aws_db_instance.db
to = aws_db_instance.primary
}With the moved block, the plan reads aws_db_instance.db has moved to aws_db_instance.primary and shows zero destroy/create — the mapping is updated, the live database is untouched. The moved block also works when you pull a resource into a module: to = module.data.aws_db_instance.primary. The failure mode is purely the absence of this: rename without it, and the destroy-by-rename trap fires on the next apply.
▸Why this works
Why prefer moved {} over terraform state mv when both fix the same thing? Because state mv is an imperative, out-of-band command: one engineer runs it against their local CLI, and if the matching config rename merges before everyone has run the same mv — or if CI applies first — the people without it get a destroy/create plan. The state and the config drift apart by who-ran-what. A moved block is declarative and version-controlled: it lives in the .tf files, merges atomically with the rename in the same commit, is visible in review, and every apply (laptop or CI) performs the move idempotently. After a release or two you can delete it. Reserve state mv for surgery you cannot express in config — splitting one address into a for_each, or emergency recovery — and make moved the default for routine refactors.
State holds secrets in plaintext, and state rm orphans on purpose
Here is the property that makes state a liability, not just an index: it stores resource attributes in plaintext, including generated secrets. An aws_db_instance with a password, a generated tls_private_key, an aws_iam_access_key secret — all land in the state file as readable JSON. Terraform marks them sensitive so they don’t print in plan output, but in the state file itself they are clear text. The consequences are concrete: a state file committed to git leaks every secret in it to anyone who clones the repo, forever in history; a state file in an unencrypted, world-readable S3 bucket is the same leak over HTTP. So state is a secret: encrypt it at rest (S3 SSE, which you set on the backend), lock down bucket access to the CI role and platform team only, never commit *.tfstate to git, and rotate any credential that ever touched an exposed state. A single leaked state file = every password, key, and token inside it compromised.
The counterpart to import is terraform state rm, which removes a resource from state without destroying the real thing. You deliberately orphan it — Terraform forgets it exists, but the live resource keeps running and billing:
# Stop managing this DB here without deleting it — hand it to another stack.
terraform state rm aws_db_instance.primary
# `plan` no longer mentions it; the live db-prod-7f3a is untouched.
# In the OTHER stack: terraform import aws_db_instance.primary db-prod-7f3aThe senior use is handing a resource between stacks during a split: state rm in the source (orphan, don’t destroy), then import into the target. The failure mode is confusing it with destroy — state rm followed by an apply in a config that still references the resource will recreate or duplicate it, and terraform destroy on a resource you meant to orphan deletes the live thing. rm forgets; destroy kills.
Blast radius: one giant state vs split state
The last senior call is how much one state file controls. A monolithic state for the whole org puts every resource — network, databases, every app — behind one lock and one plan. The costs compound: every apply refreshes and risks everything (a typo in an app module’s plan now lists 500 resources, any of which a fat-fingered approve could touch), plans take minutes because they refresh hundreds of resources against the API, and the single state file is a single point of catastrophe — corrupt or lose it and the whole estate is unmanaged at once. There is also lock contention: one shared lock means one stuck apply (a crashed CI job that never released the lock) blocks the entire org from applying until someone runs terraform force-unlock <LOCK_ID> — which you do only after confirming no apply is genuinely in flight, because force-unlocking a live apply corrupts state.
Splitting state by component and environment — network/, data/, app/ per env, each its own state and lock — shrinks the blast radius: an app deploy can never touch the VPC, plans drop to seconds because each refreshes tens not hundreds of resources, and a corrupt state damages one layer, not all. The cost is wiring: layers that need each other’s outputs read them across states with a terraform_remote_state data source, which adds explicit ordering (apply network before app) and coupling to manage:
# In the app stack: read the VPC's outputs from the network stack's state.
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "acme-tf-state"
key = "prod/network/terraform.tfstate"
region = "eu-central-1"
}
}
resource "aws_instance" "api" {
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_id
}The senior heuristic: split by lifecycle and blast radius. Things that change at different rates and would be catastrophic together belong in separate states — the VPC (rarely changes, breaks everything) apart from the app (deploys hourly), data apart from compute. Don’t split so finely that every change becomes a five-state orchestration; split at the seams where blast radius and change-frequency actually differ.
You need to rename a managed production RDS instance's resource address in config (aws_db_instance.db → aws_db_instance.primary) for readability, with zero data loss and zero downtime, and the change must apply correctly for every teammate and in CI. Pick the approach.
A teammate created an S3 bucket by hand in the console. You want Terraform to manage it going forward without recreating it. Which command brings it under management?
- 01Why does renaming a resource in config without a moved block destroy and recreate it, and what are the two safe fixes?
- 02Why is the state file a secret, and how do import, state rm, and state splitting each relate to safe state operations?
The state file is the authoritative map from each config resource address to a real cloud resource ID, and terraform plan is a three-way diff of config, state, and reality that reconciles them literally: an address in config but not state plans a CREATE, and an address in state but not config plans a DESTROY. That literal rule is the root of most state disasters — rename a resource in config without telling state and Terraform reads it as the old address being deleted and a new one created, so a cosmetic rename of a production RDS instance plans -/+ (destroy then recreate empty) and loses the database on apply. Three operations keep the mapping honest: terraform import adopts an existing unmanaged resource into state so it isn’t recreated as a colliding duplicate; terraform state mv (or, preferred, a declarative moved block committed with the rename) renames an address in state without touching the real resource, which is the fix for the destroy-by-rename trap; and terraform state rm removes a resource from state without destroying it, for deliberately orphaning a resource before handing it to another stack. State is also a secret — it stores attributes, including generated passwords and private keys, in plaintext — so it must be encrypted at rest, access-locked, and never committed to git, and any credential in a leaked state is compromised. Finally, blast radius is a design decision: a monolithic state makes every apply risk everything, takes minutes to plan, is a single point of catastrophe, and serializes the whole org behind one lock (recoverable with force-unlock, carefully), whereas splitting state by lifecycle and blast radius — network, data, app per environment, joined with terraform_remote_state data sources — shrinks risk and cuts plan time to seconds at the cost of ordering and cross-state wiring. Now when you see a rename-only PR touching .tf files, your first question will be: where is the moved block?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.