CoursesTerraformRefactoring: import, moved & state ops

Refactoring: import, moved & state ops

Adopt and reshape without destroying.

Advanced14 min · lesson 11 of 15

A keyring tells you nothing about the building. It only tells you which paper tag goes with which physical key. Terraform state is that keyring: one file listing every object Terraform believes it built. Each line ties one address in your code, like aws_instance.web, to exactly one real object in a cloud account, like the EC2 (Elastic Compute Cloud, Amazon's rented virtual machines) instance i-0abc123def4567890. Terraform never goes hunting on its own. If a running server has no tag on the ring, Terraform decides it does not exist and offers to build a second one. If a tag points at something a colleague deleted by hand, Terraform decides the server is gone and offers to rebuild it.

That single fact explains almost every alarming plan you will ever read. Rename a resource in your code and you have renamed nothing in the cloud. You threw one paper tag in the bin and wrote a fresh blank one. Terraform reads that as: destroy the old thing, create the new thing. On a scratch test box, fine. On the production database, that is an outage, a restore from backup, and a very long evening. The fix is never to argue with the plan. The fix is to edit the keyring so it says what you meant.

terminal
$ terraform state list
output
aws_instance.web
aws_security_group.web
aws_s3_bucket.logs
module.vpc.aws_vpc.this
module.vpc.aws_subnet.private[0]
module.vpc.aws_subnet.private[1]

Read those addresses closely, because every operation in this lesson rewrites one line of that list. A bare name like aws_s3_bucket.logs lives in the root module (the top-level folder Terraform started in). A module. prefix means the resource was declared inside a reusable sub-folder you called from there. A trailing [0] or ["blue"] is the instance key, the tail Terraform adds when you asked for many copies with count (numbered copies) or for_each (copies keyed by name). Change any character of that string in your code and Terraform sees a different resource.

Your code address and the real object do not line up
Your code and the account disagree. Which way?
nothing exists yet
Ordinary create
plan shows + create, apply builds it
object exists, no state entry
import block
plan reads 1 to import, 0 to change
same object, new address
moved block
plan reads has moved to, 0 to destroy
keep the object, stop managing it
removed block
plan reads will no longer be managed

Import Adopts What Already Exists

Adoption is the case where the door and the key both exist and nobody ever wrote a tag for the ring. Someone clicked through the web console at 2am during an incident. A retired shell script created the bucket in 2021. Another team handed you an account and a spreadsheet. The resource is real, it is serving traffic, and your code has never heard of it. An import block hands Terraform the key and tells it which tag to write.

/infra/prod/main.tf
import {
to = aws_instance.web # the address you want it to have in state
id = "i-0abc123def4567890" # the real object that already exists
}
resource "aws_instance" "web" {
ami = "ami-0c7217cdde317cfec"
instance_type = "t3.micro"
subnet_id = "subnet-0f1e2d3c4b5a69788"
}

Import blocks arrived in Terraform 1.5 and they replaced a much worse habit. The old command, terraform import aws_instance.web i-0abc123def4567890, writes to state the moment you press Enter. No plan first. No review. No trace in version control that it ever happened. An import block is ordinary code, so it travels through a pull request, a plan, and whatever policy check (the automated rule that reads a plan and blocks anything against house rules) you run. Save the plan to a file and read it before anyone applies it. Once the apply succeeds, the block has done its one job and you can delete it.

terminal
$ terraform plan -out=tfplan
output
aws_instance.web: Preparing import... [id=i-0abc123def4567890]
aws_instance.web: Refreshing state... [id=i-0abc123def4567890]
Terraform will perform the following actions:
# aws_instance.web will be imported
resource "aws_instance" "web" {
ami = "ami-0c7217cdde317cfec"
arn = "arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123def4567890"
associate_public_ip_address = true
availability_zone = "us-east-1a"
id = "i-0abc123def4567890"
instance_state = "running"
instance_type = "t3.micro"
private_ip = "10.0.3.17"
subnet_id = "subnet-0f1e2d3c4b5a69788"
tags = {
"Name" = "web"
}
}
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"

One to import, zero to change is the sentence you are hunting for. It means Terraform found the object, compared every attribute against your configuration, and concluded there is nothing to alter. Adoption is a security result before it is a tidiness result. Hand-built resources are exactly the ones that skipped your defaults: no encryption at rest, no access logging, no required cost-centre tag, no lifecycle rule. Your scanners and policy engines read plans, and this resource had never appeared in a plan, so no rule had ever been applied to it. The moment it is imported, it sits inside the review loop, and every later change to it lands as a diff somebody has to approve.

For a large adoption you rarely want to hand-write forty resource blocks. Two features carry the load. Since Terraform 1.7 an import block accepts for_each, so a single block can adopt a whole list of objects at once.

/infra/prod/adopt.tf
locals {
legacy_buckets = {
artifacts = "acme-prod-artifacts"
backups = "acme-prod-backups"
}
}
import {
for_each = local.legacy_buckets
to = aws_s3_bucket.legacy[each.key] # one state entry per map key
id = each.value # the real bucket name in the account
}

The second feature saves you the typing. Write the import block on its own, with no matching resource block anywhere, and Terraform will draft the configuration from the live object and write it to a file you name.

terminal
$ terraform plan -generate-config-out=generated.tf
output
aws_s3_bucket.artifacts: Preparing import... [id=acme-prod-artifacts]
aws_s3_bucket.artifacts: Refreshing state... [id=acme-prod-artifacts]
Terraform will perform the following actions:
# aws_s3_bucket.artifacts will be imported
# (config will be generated)
resource "aws_s3_bucket" "artifacts" {
bucket = "acme-prod-artifacts"
force_destroy = false
tags = {}
}
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
Terraform has generated configuration and written it to generated.tf. Please
review the configuration and edit it as necessary before adding it to version
control.

Treat generated.tf as a first draft written by a machine that has never met your team. It hard-codes literal identifiers where you want references to other resources or to variables. It sometimes emits attributes the provider only reports back and will not accept as input, which then blow up on the next plan. It knows nothing about your tagging module or your lifecycle rules. Terraform also refuses to write over the file if it already exists, so clear it between attempts. Read every line, wire up the references, then commit it.

Read an Import Plan Like an Auditor

The dangerous import is the one that looks like it worked. Here is the same adoption, except the AMI (Amazon Machine Image, the disk template an instance boots from) written in your code is not the one the running instance actually booted from.

output
# aws_instance.web must be replaced
# (imported from "i-0abc123def4567890")
# Warning: this will destroy the imported object
-/+ resource "aws_instance" "web" {
~ ami = "ami-0e86e20dae9224db8" -> "ami-0c7217cdde317cfec" # forces replacement
~ arn = "arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123def4567890" -> (known after apply)
~ id = "i-0abc123def4567890" -> (known after apply)
# (28 unchanged attributes hidden)
}
Plan: 1 to import, 1 to add, 0 to change, 1 to destroy.
An import plan is not read-only
The -/+ symbol means replace: destroy, then create. Terraform will happily adopt a live production instance and terminate it in the same apply, because you told it the configuration is the truth and the configuration disagrees. The tells are the phrase must be replaced, the # forces replacement comment on the offending attribute, and any non-zero number in the destroy column. If an import plan reads anything other than 0 to add, 0 to change, 0 to destroy, stop and bend the config to match reality first.

You can also fit a seatbelt. prevent_destroy turns that silent replacement into a hard failure at plan time, before anyone gets the chance to approve it.

/infra/prod/main.tf
resource "aws_instance" "web" {
ami = "ami-0c7217cdde317cfec"
instance_type = "t3.micro"
subnet_id = "subnet-0f1e2d3c4b5a69788"
lifecycle {
prevent_destroy = true # refuse to produce any plan that destroys this object
}
}
output
│ Error: Instance cannot be destroyed
│ on main.tf line 1:
│ 1: resource "aws_instance" "web" {
│ Resource aws_instance.web has lifecycle.prevent_destroy set, but the plan
│ calls for this resource to be destroyed. To avoid this error and continue
│ with the plan, either disable lifecycle.prevent_destroy or reduce the scope
│ of the plan using the -target option.

One caveat, because the seatbelt is bolted to the configuration rather than to the object. prevent_destroy stops a plan; it does not protect the resource. Delete the resource block and the guard leaves with it. terraform state rm walks straight past it. What it buys you is a loud failure during exactly the kind of refactor covered here, which is when you need one. Deletion protection on the database itself, set in the cloud provider, is the belt that stays fastened when the code changes.

Moved Rewrites the Tag, Not the Key

Refactoring is adoption in reverse. The key on your ring is right; the label is wrong, or filed in the wrong pocket. You are renaming aws_instance.web to something clearer, pulling it into a module, or switching count to for_each so your subnets stop shuffling every time you delete one from the middle of the list. A moved block, available since Terraform 1.1, says these two addresses are the same object, so move the entry rather than tear down the building.

/infra/prod/moved.tf
# renamed and pulled into a module
moved {
from = aws_instance.web
to = module.web.aws_instance.this
}
# count index -> for_each key, the switch that otherwise rebuilds everything
moved {
from = aws_subnet.private[0]
to = aws_subnet.private["eu-west-1a"]
}
# whole module instances work the same way
moved {
from = module.app
to = module.app["prod"]
}
terminal
$ terraform plan
output
Terraform will perform the following actions:
# aws_instance.web has moved to module.web.aws_instance.this
resource "aws_instance" "web" {
id = "i-0abc123def4567890"
instance_type = "t3.micro"
# (31 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.

Zero, zero, zero, with a has moved to line above it. That is a pure keyring edit and nothing in the account gets touched. Two rules keep it that way. First, the from address must no longer exist in your configuration, or Terraform sees two claims on one object and errors out. Second, from and to must be the same resource type. You cannot move an aws_instance into an aws_db_instance, because the attributes mean different things.

Leave moved blocks in place far longer than feels necessary. A moved block is a one-time instruction to any state file that has not yet seen it. If dev, staging and production run on separate release trains, production might not apply for three weeks. Deleting the block early throws no error and prints no warning. It quietly turns the next production plan back into a destroy and a create for whoever was running behind.

The Imperative Escape Hatch

terraform state mv and terraform state rm are those same two edits done by hand, immediately, with no plan and no pull request. Keep them for what a block cannot express: shifting objects between two separate state files (pull both down, move between the local copies with -state and -state-out, push them back), or repairing a state so broken that plan will not even run. Everything else belongs in code, where a second person can read it before it happens.

terminal
$ umask 077
$ terraform state pull > state-before.json # a copy you can roll back to
$ terraform state mv aws_instance.web module.web.aws_instance.this
output
Move "aws_instance.web" to "module.web.aws_instance.this"
Successfully moved 1 object(s).

The opposite move is forgetting an object without deleting it. You are handing a resource to another team's repository, or splitting one enormous state into three. Since Terraform 1.7 a removed block does that as reviewable code: take the resource block out of your configuration and put this in its place.

/infra/prod/removed.tf
removed {
from = aws_instance.legacy
lifecycle {
destroy = false # forget it, do not delete it. destroy = true would delete it.
}
}
terminal
$ terraform plan
output
Terraform will perform the following actions:
# aws_instance.legacy will no longer be managed by Terraform, but will not be destroyed
. resource "aws_instance" "legacy" {
id = "i-09fedcba98765432f"
instance_type = "t3.small"
}
Plan: 0 to add, 0 to change, 0 to destroy.

Now the defender's side of the same tool, because you should know what this looks like from the outside. terraform state rm is a quiet way to make a resource invisible to your pipeline. Someone with pipeline access deletes a security group from the code and from the state in one commit. Nothing is destroyed. Nothing appears in any later plan. The group carries on doing its job in the account, now completely outside review, and a fortnight later they widen it by hand to 0.0.0.0/0 (every address on the internet). Your Terraform runs stay green forever, because Terraform only reconciles what is on the keyring.

Three habits catch that. Keep the state object versioned, so every write lands as a separate, diffable version and yesterday's copy is one command away; on S3 (Simple Storage Service, Amazon's object storage) that means bucket versioning plus denying the pipeline role permission to delete old versions. Turn on object-level logging for that bucket, called data events in CloudTrail, so a state pull by an unusual identity is visible rather than invisible. And run drift detection on a schedule that compares the live account against your state, because a resource that left the state also left the plan, and a check that only reads plans will never mention it again.

State Is a Secrets File

Every attribute a provider hands back gets written into state as plain JSON (JavaScript Object Notation, a plain-text data format). Database master passwords. Generated private keys. Bootstrap tokens. Marking a variable sensitive = true hides the value from terminal output and does nothing else; it does not encrypt a single byte on disk. Anyone who can read the state can read the credentials. Terraform 1.10 and 1.11 added ephemeral values and write-only arguments so that some secrets never reach state at all, which is worth adopting for new work, but every state file you already have still holds whatever it collected along the way.

terminal
$ umask 077 # so the redirect creates 0600, not 0644
$ terraform state pull > /dev/shm/state.json # /dev/shm is RAM-backed, never hits the disk
$ jq -r '[paths(scalars) | map(tostring) | join(".")]
| map(select(test("password|private_key|secret";"i")))[]' /dev/shm/state.json
output
resources.4.instances.0.attributes.password
resources.7.instances.0.attributes.private_key
resources.7.instances.0.attributes.private_key_openssh

Handle a pulled state like a credential dump. Set the umask before the redirect, not chmod after it, or there is a window where the file sits world-readable. Delete it the second you are done, and never let it near the repository. Do not lean on shred: on a solid state drive or a copy-on-write filesystem the block you overwrite is rarely the block that held the data, so keeping the file in RAM beats trying to erase it afterwards. Terraform also leaves terraform.tfstate.backup beside a local state after every write, and drops errored.tfstate in the working directory when a state write fails partway through an apply. Both hold the same secrets. Neither appears in most .gitignore files by default.

State operations take the same lock an apply does, the way a shared document goes read-only while a colleague has it open, so a run that died hard can leave one behind. The error prints a Lock Info block with ID, Who and Created fields. Check that identity and timestamp against your running CI (continuous integration, the automated pipeline that runs your plans) jobs before you touch anything. Running terraform force-unlock against a job that is still writing leaves you a state file describing half an apply, which is far harder to repair than waiting ten minutes. Set -lock-timeout=300s on pipeline runs so concurrent jobs queue instead of failing, and on the S3 backend recent Terraform can hold the lock as an object in the same bucket with use_lockfile = true.

output
│ Error: Error acquiring the state lock
│ Lock Info:
│ ID: 3f2b1c9a-7d4e-4f21-9a3b-1c2d3e4f5a6b
│ Path: tf-state-prod/network/terraform.tfstate
│ Operation: OperationTypeApply
│ Who: ci-runner@runner-7
│ Version: 1.13.3
│ Created: 2026-07-21 09:14:22.118374 +0000 UTC
│ Info:
│ Terraform acquires a state lock to protect the state from being written
│ by multiple users at the same time. Please resolve the issue above and try
│ again. For most commands, you can disable locking with the "-lock=false"
│ flag, but this is not recommended.

Make the Pipeline Read the Plan for You

Humans skim. A saved plan file can be checked by something that does not. terraform show -json turns a plan into structured data, and three short queries with jq (a small command-line tool for pulling fields out of JSON) cover nearly every mistake in this lesson: any deletion at all, every address that moved, and every object being adopted.

terminal
$ terraform plan -out=tfplan > /dev/null
$ terraform show -json tfplan > plan.json
$ jq '[.resource_changes[] | select(.change.actions | index("delete"))] | length' plan.json
$ jq -r '.resource_changes[] | select(.previous_address) | "\(.previous_address) -> \(.address)"' plan.json
$ jq -r '.resource_changes[] | select(.change.importing) | "\(.address) <= \(.change.importing.id)"' plan.json
output
0
aws_instance.web -> module.web.aws_instance.this
aws_s3_bucket.artifacts <= acme-prod-artifacts

Wire the first query into a job that fails the build whenever the count rises above zero on a pull request labelled as a refactor or an adoption. A refactor that deletes something is not a refactor. The other two hand a reviewer a one-line statement of intent, and they double as an audit trail: which real cloud identifiers this repository claimed, on which commit, approved by whom.

Then run the same configuration on a timer with -detailed-exitcode, which returns 0 for no changes, 2 for changes waiting, and 1 for an error. A nightly job that expects 0 and pages a human on 2 tells you within a day that somebody edited a managed resource by hand. That is the half of the story state operations cannot see: state tells you what Terraform believes, and this tells you where reality has wandered off.

terminal
$ terraform plan -detailed-exitcode -lock-timeout=300s
$ echo "exit code: $?"
output
No changes. Your infrastructure matches the configuration.
Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
exit code: 0
Quick check
01Your import plan finishes with: Plan: 1 to import, 0 to add, 1 to change, 0 to destroy. What have you actually been told?
Correct — The import binds the object to the address, then the plan's diff is applied to the live resource. A clean adoption reads 0 to change.
Incorrect — A failed import stops with an error, and the add column here is 0, so nothing new is being created.
Incorrect — A matching import genuinely reports 0 to change. Any non-zero change count is a real difference against live infrastructure.
Incorrect — The import and the resulting modification happen in the same apply, against the real resource.
02You add lifecycle { prevent_destroy = true } to a production database resource. What does that setting actually protect you against?
Incorrect — that describes provider-side deletion protection, a separate setting on the cloud object; prevent_destroy lives only in the configuration.
Incorrect — prevent_destroy takes no backup of anything; it simply refuses to produce the plan.
Incorrect — it guards one resource at plan time, and removing the block or narrowing scope with -target walks around it; it is not a workspace-wide lock.
Correct — prevent_destroy is a plan-time guard tied to the config; protection that survives code changes has to be set on the object in the cloud provider.
03Your subnets are declared with count = 3, so their state addresses are aws_subnet.private[0] through [2]. Because deleting the middle one renumbers the rest, you switch the resource to for_each keyed by availability-zone name. A plain terraform plan now shows all three subnets destroyed and recreated. How do you make this a pure keyring edit with nothing rebuilt?
Incorrect — once forgotten, the next apply builds three brand-new subnets and orphans the originals, which is exactly the outage you were avoiding.
Incorrect — that only turns the recreate into a hard plan error; it never maps the old addresses to the new keys, so the plan still cannot proceed.
Correct — a moved block tells Terraform the two addresses are the same object, so it edits the state entry instead of tearing anything down, and the plan reads 0 to add, 0 to change, 0 to destroy.
Incorrect — that flag drafts configuration for importing an existing object; it does not move state entries from one address to another.

Try this

Run terraform state list on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: an import plan is not read-only. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related