Learn Terraform - Advanced State Manipulation (import, moved, state)
Episode 8 of 21

Learn Terraform - Advanced State Manipulation (import, moved, state)

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.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

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.

Main Discussion

Connecting Existing Infrastructure to Terraform

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.

Legacy: terraform import (CLI)

Before Terraform 1.5, the only way was running a command in the terminal. The syntax is simple:

plaintext
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 (legacy)
terraform import aws_instance.web i-0abcd1234efgh5678
Import output
aws_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:

  • Doesn't write code. The resource block must already exist in the configuration before import; otherwise, the following plan will try to destroy that resource.
  • One command for one resource — importing hundreds of resources means hundreds of commands.
  • Not idempotent in a single run: if something fails halfway, you have to re-run the rest.
  • ID information must be known manually (from the console, awscli, etc.).

Modern: 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:

import-block.tf
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:

  • The import block lives side by side with its resource block — one source of truth.
  • Running terraform plan alone is enough to verify; the plan will show "Plan: 1 to import".
  • The import is executed by terraform apply, and the import block can be left in place or removed after success.
  • Easier to review in a Pull Request — who imports what is recorded in git.

The terraform plan output for the config above will look like this:

terraform plan output
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.

The Correct Import Workflow

Even though the mechanisms differ, the workflow is the same and you must memorize it:

  1. Write the resource block representing the existing object (minimal attributes, at least the ones forming the identity like bucket, name, ami).
  2. Declare the import — via an import block (modern) or the terraform import command (legacy).
  3. Run 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.
  4. Run 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.

Comparison: Legacy vs Modern

Aspectterraform import (CLI)import block (declarative)
Written inTerminalConfiguration code (.tf)
Requires Terraformall versions1.5+
Reviewable via gitnoyes
Mass importone by oneall at once in apply
Idempotentnot automaticyes
Verificationmanual / next plandirectly in plan
Recommendationad-hoc/scripting casesdefault for new projects
terraform import aws_s3_bucket.data "my-existing-bucket"

Refactoring Code Without Destroying Resources

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.

The moved Block: Refactoring Written in Code

Terraform 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."

moved-block.tf
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:

terraform plan output
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 Terminal

There 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
terraform state mv aws_instance.web aws_instance.nginx
plaintext
Move "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:

Other state mv examples
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.primary
moved {
  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.

State Cleanup & Inspection

The terraform state family also provides commands to view state contents and detach resources from Terraform management without touching the cloud.

terraform state list

Displays all resource addresses registered in state — the fastest way to see "who Terraform currently manages":

terraform state list
terraform state list
state list output
aws_instance.nginx
aws_security_group.web
aws_subnet.app
aws_vpc.main
module.db.aws_db_instance.primary

terraform state show

Displays the attribute details of one specific resource — like a state list combined with deep inspection:

terraform state show
terraform state show aws_instance.nginx
state show output
# 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 rm

Detaches 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
terraform state rm aws_instance.nginx
plaintext
Removed aws_instance.nginx
Successfully removed 1 resource instance(s).

Legitimate usage scenarios:

  • A resource intentionally handed back to manual management (for example an old bucket handed over to another team).
  • Removing a resource that was accidentally imported.
  • Decommissioning a resource in a controlled way: prevent_destroy + state rm + remove code.

terraform refresh and -refresh-only

terraform 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:

refresh-only
terraform plan -refresh-only

terraform state Command Table

SubcommandFunction
terraform state listDisplays all resource addresses in state
terraform state showDisplays the attribute details of a specific resource
terraform state mvMoves resources between addresses / state files
terraform state rmRemoves resources from state without destroying in the cloud
terraform state pullFetches & prints raw state (JSON) from the backend
terraform state pushOverwrites the backend state with a pulled file (dangerous)
terraform state replace-providerReplaces the provider name in state (e.g. during provider migration)

Common Pitfalls

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.

Conclusion

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!

Learn Terraform - Advanced State Manipulation (import, moved, state) | Learn Terraform