In this episode we'll connect existing infrastructure to Terraform with the import block, restructure code without destruction using moved, and inspect and clean up state through terraform state commands.

After discussing Resource Dependencies & Lifecycle Rules in episode 7 — implicit vs explicit dependencies (depends_on), as well as lifecycle rules like create_before_destroy, prevent_destroy, and ignore_changes — in this episode we'll move into terrain very often faced by practitioners in the field: dealing with the state file directly.
Let's be honest: almost no team starts an IaC project from scratch. In reality, infrastructure already stands first — built via the console, old scripts, or the hands of another team that has already resigned. Then comes a new project: "migrate everything to Terraform." The first question that arises: do we have to destroy everything and recreate it? The answer is firm: no.
On the other side, code also keeps evolving. A resource that was named aws_instance.web might need to be moved into a module, or renamed to aws_instance.nginx. If we delete the old resource and rewrite a new one, Terraform will destroy and recreate — something we absolutely don't want.
In this episode you'll master the ultimate state management arsenal: import, moved, and the big family of terraform state. All three allow us to change Terraform's perspective on infrastructure — without destroying anything in the cloud.
The basic concept is called importing: bringing objects that already exist in the cloud into the Terraform state file, so Terraform starts "acknowledging" and managing them. It's important to understand from the start: import only writes to state, doesn't create resources, and doesn't write configuration code for us.
There are two ways to import: the old CLI-based approach, and the modern declarative approach.
terraform import (CLI)Before Terraform 1.5, the only way was running a command in the terminal. The syntax is simple:
terraform import <address> <id>Where <address> is the resource address in code (e.g. aws_instance.web) and <id> is the object's ID in the cloud (e.g. i-0abcd1234efgh5678). In practice:
terraform import aws_instance.web i-0abcd1234efgh5678aws_instance.web: Importing from ID "i-0abcd1234efgh5678"...
aws_instance.web: Import prepared!
Prepared aws_instance.web for import
aws_instance.web: Refreshing state... [id=i-0abcd1234efgh5678]
Import successful!
The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.Several characteristics of the CLI approach worth noting:
plan will try to destroy that resource.awscli, etc.).import Block (Terraform 1.5+)Starting with Terraform 1.5, import can be written declaratively in code as an import block, side by side with the resource definition. Its form:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = {
Name = "web-server"
}
}
import {
to = aws_instance.web
id = "i-0abcd1234efgh5678"
}What makes this approach far superior:
import block lives side by side with its resource block — one source of truth.terraform plan alone is enough to verify; the plan will show "Plan: 1 to import".terraform apply, and the import block can be left in place or removed after success.The terraform plan output for the config above will look like this:
Terraform will perform the following actions:
# aws_instance.web will be imported
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890"
id = "i-0abcd1234efgh5678"
instance_type = "t3.micro"
...
}
Plan: 1 to import.Even though the mechanisms differ, the workflow is the same and you must memorize it:
bucket, name, ami).import block (modern) or the terraform import command (legacy).terraform plan to verify: Plan: 1 to import means it's clean. If the plan shows 1 to change, your configuration doesn't match the real condition yet — adjust the code, don't rush to apply.terraform apply to complete the import.Important
The most fatal mistake: running terraform import without writing the resource block first. The import successfully enters state, but because there's no matching block, the next terraform plan will show "Plan: 1 to destroy". Terraform assumes that resource is no longer declared — and apply will destroy it in the cloud. Always write code before importing, and always check plan before apply.
| Aspect | terraform import (CLI) | import block (declarative) |
|---|---|---|
| Written in | Terminal | Configuration code (.tf) |
| Requires Terraform | all versions | 1.5+ |
| Reviewable via git | no | yes |
| Mass import | one by one | all at once in apply |
| Idempotent | not automatic | yes |
| Verification | manual / next plan | directly in plan |
| Recommendation | ad-hoc/scripting cases | default for new projects |
terraform import aws_s3_bucket.data "my-existing-bucket"After infrastructure is imported, code keeps evolving: resources get renamed, moved into modules, or merged with other resources. Naively, if we just change the name in code, Terraform will interpret it as delete the old, create the new — destroy and recreate. There are two mechanisms to avoid that.
moved Block: Refactoring Written in CodeTerraform 1.1 introduced the moved block — a declarative way to tell Terraform: "the resource at the old address now lives at a new address, treat them the same."
resource "aws_instance" "nginx" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
}
moved {
from = aws_instance.web
to = aws_instance.nginx
}When running terraform plan, the output will clearly show the relocation:
aws_instance.nginx: Refreshing state... [id=i-0abcd1234efgh5678]
Moved:
aws_instance.web → aws_instance.nginx
Plan: 0 to add, 0 to change, 0 to destroy.Notice Plan: 0 to destroy — the resource isn't destroyed, just "moved" within state. After apply succeeds, the moved block may be removed from the code (Terraform even suggests cleanup in the apply output). Keeping it longer isn't harmful either — the block is only active while the old address is still referenced in state, and it could cause confusion if that address is used again in the future.
terraform state mv: Direct Refactoring in the TerminalThere are times when we can't (or don't want to) change the code first — for example restructuring many resources in one session, or moving resources between state files. For that case, terraform state mv works directly in the terminal:
terraform state mv aws_instance.web aws_instance.nginxMove "aws_instance.web" to "aws_instance.nginx"
Successfully moved 1 item(s).terraform state mv also works to move resources into modules or vice versa, as well as between state backends:
terraform state mv 'module.web.aws_instance.app' 'aws_instance.legacy'
terraform state mv -state=old.tfstate -state-out=new.tfstate module.db.aws_db_instance.primary module.db.aws_db_instance.primarymoved {
from = aws_instance.web
to = aws_instance.nginx
}Tip
The selection rule: use the moved block when the refactoring is part of a code change that will be reviewed (default for projects, versioned code), terraform state mv when the state action is one-off, automated/scripted, or involves moving between state files. After state mv, remember to update the configuration code to the new address — otherwise, plan will consider the old resource missing and try to recreate it.
The terraform state family also provides commands to view state contents and detach resources from Terraform management without touching the cloud.
terraform state listDisplays all resource addresses registered in state — the fastest way to see "who Terraform currently manages":
terraform state listaws_instance.nginx
aws_security_group.web
aws_subnet.app
aws_vpc.main
module.db.aws_db_instance.primaryterraform state showDisplays the attribute details of one specific resource — like a state list combined with deep inspection:
terraform state show aws_instance.nginx# aws_instance.nginx:
resource "aws_instance" "nginx" {
ami = "ami-0abcdef1234567890"
arn = "arn:aws:ec2:ap-southeast-1:123456789012:instance/i-0abcd1234efgh5678"
id = "i-0abcd1234efgh5678"
instance_type = "t3.micro"
private_dns = "ip-10-0-1-5.ap-southeast-1.compute.internal"
private_ip = "10.0.1.5"
public_ip = "203.0.113.10"
subnet_id = "subnet-0abc123"
vpc_security_group_ids = ["sg-0def456"]
}terraform state rmDetaches a resource from state without deleting it in the cloud. After this command, Terraform no longer manages that resource — it becomes "wild" (orphan) from an IaC perspective, but stays alive and still incurs costs.
terraform state rm aws_instance.nginxRemoved aws_instance.nginx
Successfully removed 1 resource instance(s).Legitimate usage scenarios:
prevent_destroy + state rm + remove code.terraform refresh and -refresh-onlyterraform refresh updates state attributes to match the real condition in the cloud without changing code and without a plan. Because refresh is actually also used by plan/apply, this command is now better replaced with terraform plan -refresh-only, whose output is safer to review:
terraform plan -refresh-onlyterraform state Command Table| Subcommand | Function |
|---|---|
terraform state list | Displays all resource addresses in state |
terraform state show | Displays the attribute details of a specific resource |
terraform state mv | Moves resources between addresses / state files |
terraform state rm | Removes resources from state without destroying in the cloud |
terraform state pull | Fetches & prints raw state (JSON) from the backend |
terraform state push | Overwrites the backend state with a pulled file (dangerous) |
terraform state replace-provider | Replaces the provider name in state (e.g. during provider migration) |
1. Import without a resource block → resource becomes "to be destroyed"
Already covered in the previous callout, but worth repeating: this is the most common cause of "infrastructure disappearing after import". Always write the resource block, plan, then apply.
2. Wrong import ID → confusing error
Importing with an invalid ID produces messages like Error: Cannot import non-existent remote object or The given ID "..." is invalid. Before importing, verify the ID from the cloud console or command-line tooling (aws ec2 describe-instances, aws s3api list-buckets, etc.).
3. state mv not followed by code updates
Moving an address in state but forgetting to change the code → the next plan shows 0 to add, 1 to destroy for the old address. Always pair state mv with the appropriate code edit.
4. Accidental state rm = hidden costs
A rm-ed resource is no longer counted by Terraform, but keeps running and being billed in the cloud. Make sure state rm is done deliberately and recorded — for example through a clear code change, not as a habit of "if it errors, just remove it from state".
5. Manually editing the state JSON file
terraform state pull then modifying the JSON with an editor then state push is a practice that almost always ends badly — the JSON structure breaks easily, and push overwrites the backend state without safeguards. Always use the official subcommands (state mv, state rm, etc.). Save state pull for backup/investigation, not for editing.
6. Leaving an import block after success
After an import block executes successfully, remove the block. Leaving it in the code will make the next apply try to re-import an already-existing resource — usually harmless (no-op), but risky if the object's ID changes.
In this episode 8 we've mastered advanced operations on the state file: importing existing infrastructure via the legacy terraform import command or the declarative import block (Terraform 1.5+) with the write-code → import → plan → apply workflow; refactoring without destruction using the moved block and terraform state mv; as well as state inspection & cleanup through terraform state list, state show, and state rm.
This capability is what makes Terraform usable not just for greenfield projects, but also for conquering legacy infrastructure that has stood for years. Destruction-free migration, downtime-free refactoring, and full control over state contents — these are the marks of a mature IaC practitioner.
However, managing hundreds of resources in one long, single file isn't a sustainable practice. How do we package configuration into clean, reusable components? In the next episode 9 we'll discuss Creating & Managing Reusable Modules — the standard module structure (main.tf, variables.tf, outputs.tf), local vs remote modules, and how modules keep code DRY. Stay excited!