Refactoring: import, moved & state ops
Adopt and reshape without destroying.
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.
$ terraform state list
aws_instance.webaws_security_group.webaws_s3_bucket.logsmodule.vpc.aws_vpc.thismodule.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.
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.
import {to = aws_instance.web # the address you want it to have in stateid = "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.
$ terraform plan -out=tfplan
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 importedresource "aws_instance" "web" {ami = "ami-0c7217cdde317cfec"arn = "arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123def4567890"associate_public_ip_address = trueavailability_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: tfplanTo 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.
locals {legacy_buckets = {artifacts = "acme-prod-artifacts"backups = "acme-prod-backups"}}import {for_each = local.legacy_bucketsto = aws_s3_bucket.legacy[each.key] # one state entry per map keyid = 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.
$ terraform plan -generate-config-out=generated.tf
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 = falsetags = {}}Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.Terraform has generated configuration and written it to generated.tf. Pleasereview the configuration and edit it as necessary before adding it to versioncontrol.
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.
# 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.
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.
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}}
╷│ 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.
# renamed and pulled into a modulemoved {from = aws_instance.webto = module.web.aws_instance.this}# count index -> for_each key, the switch that otherwise rebuilds everythingmoved {from = aws_subnet.private[0]to = aws_subnet.private["eu-west-1a"]}# whole module instances work the same waymoved {from = module.appto = module.app["prod"]}
$ terraform plan
Terraform will perform the following actions:# aws_instance.web has moved to module.web.aws_instance.thisresource "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.
$ 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
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.
removed {from = aws_instance.legacylifecycle {destroy = false # forget it, do not delete it. destroy = true would delete it.}}
$ terraform plan
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.
$ 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
resources.4.instances.0.attributes.passwordresources.7.instances.0.attributes.private_keyresources.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.
╷│ 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.
$ 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
0aws_instance.web -> module.web.aws_instance.thisaws_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.
$ terraform plan -detailed-exitcode -lock-timeout=300s$ echo "exit code: $?"
No changes. Your infrastructure matches the configuration.Terraform has compared your real infrastructure against your configurationand found no differences, so no changes are needed.exit code: 0
lifecycle { prevent_destroy = true } to a production database resource. What does that setting actually protect you against?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?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.