State Operations: Import, Move, Refactor & Drift

State Is Not Read-Only

Most engineers treat the state file like it’s radioactive. And look, healthy respect is good — it IS the single source of truth for what Terraform manages. But it’s not untouchable. Terraform gives you safe, auditable commands for manipulating state when the situation demands it.

And the situation will demand it. You’ll need to import infrastructure that someone built by clicking around in the console. You’ll rename resources during a refactor. You’ll reorganize modules. You’ll discover drift at 11 PM on a Thursday.

terraform import — Bringing Existing Resources Under Management

You’ve got infrastructure that was created manually — or by CloudFormation, or by an intern, or by “I just needed to test something real quick” — and now you want Terraform to own it going forward. terraform import pulls it into state.

The modern way — the import block (Terraform 1.5+):

# main.tf — declare the resource and the import block
import {
  id = "i-0abc123def456789"    # The real cloud resource ID
  to = aws_instance.web        # The Terraform resource address
}

resource "aws_instance" "web" {
  # You can leave this empty initially, or fill it in
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"
}

Run terraform plan — Terraform shows you the difference between the real resource and your config. Run terraform apply — the resource is added to state. Clean.

Even better: in Terraform 1.6+, terraform plan -generate-config-out=generated.tf will actually generate the resource config for you from the imported resource. Massive time saver when importing resources with 30+ attributes.

The legacy CLI approach (still works, still on the exam):

terraform import aws_instance.web i-0abc123def456789

Adds the resource to state without touching config. You then have to write the matching resource block yourself — and if your config doesn’t match the real resource, the next terraform plan will show changes or outright errors. The import block approach is better in every way, but you’ll see the CLI version in older codebases everywhere.

moved Block — Renaming Without Destroying

This one saves lives. You renamed a resource in your config (because the original name was terrible), or you refactored it into a module. Terraform looks at the state, sees the old address gone and a new address that doesn’t exist in state — and plans to destroy the old one and create a brand new one.

For an EC2 instance, that means downtime. For an RDS database, that means data loss. Not great.

The moved block tells Terraform: same resource, different address. Don’t touch it.

# Before refactoring: resource "aws_instance" "server" { ... }
# After refactoring: resource "aws_instance" "web_server" { ... }

moved {
  from = aws_instance.server
  to   = aws_instance.web_server
}

Terraform updates the address in state. Infrastructure is completely untouched. Your blood pressure stays normal.

# Moving a resource into a module
moved {
  from = aws_instance.web
  to   = module.compute.aws_instance.web
}

Once everyone on the team has applied the move, you can remove the moved block. Or leave it — it’s harmless and serves as documentation of the rename.

removed Block — Removing from Management Without Deleting

Sometimes you want Terraform to forget about a resource without actually destroying it. Maybe another team is taking ownership. Maybe you’re migrating to a different tool.

removed {
  from = aws_instance.old_server

  lifecycle {
    destroy = false  # Remove from state but don't destroy the real resource
  }
}

The instance keeps running. Terraform just stops tracking it. With destroy = true, it works like a normal deletion but with explicit intent documented right there in the config.

terraform state Commands — Direct State Surgery

Sometimes you need to manipulate state directly, without config changes. These are your scalpels:

# List all resources in state
terraform state list

# Show detailed state for a specific resource
terraform state show aws_instance.web

# Move a resource to a new address in state
terraform state mv aws_instance.web aws_instance.webserver

# Remove a resource from state (doesn't destroy the real resource)
terraform state rm aws_instance.old_server

# Pull raw state JSON
terraform state pull

# Push state (dangerous — overwrites remote state)
terraform state push terraform.tfstate

terraform state mv is the old way to rename resources. The moved block (Terraform 1.1+) is better — it’s declarative, shows up in plan output, is reviewable in PRs, and doesn’t require a special command on every team member’s machine. Use moved blocks for new work. You’ll still use state mv in emergency situations.

One thing that catches people: terraform state rm removes from state but does NOT destroy the cloud resource. The resource becomes orphaned — running in your cloud, costing money, not managed by anything. Be intentional about this.

Refresh-Only Mode — Syncing State with Reality

State drifts from reality. It just does. Someone tweaks a security group rule in the console during an incident. An automated backup process modifies a tag. A Lambda function auto-scales a DynamoDB table.

refresh-only mode updates Terraform’s state to reflect what actually exists, without changing any infrastructure.

# See what has drifted
terraform plan -refresh-only

# Accept the drift (update state to match reality)
terraform apply -refresh-only

When to use it:

  • After someone made manual changes you want to formally accept
  • When you want to see what’s drifted before deciding whether to revert or accept

Here’s the distinction that matters: terraform apply -refresh-only does NOT revert drift. It accepts the drift. It says “okay, this is the new truth.” If you want to REVERT drift (force the cloud back to match your config), run a normal terraform apply.

Drift Detection in Practice
# 1. Run a refresh-only plan to see what's changed
terraform plan -refresh-only

Output shows resources modified outside Terraform:

  ~ resource "aws_security_group" "web" {
      ~ description = "Web server SG" -> "Modified manually"
        ...
    }

Plan: 0 to add, 0 to change, 0 to destroy.
Changes to Outputs:
Note: Objects have changed outside of Terraform

Now you decide:

  • Accept the change: terraform apply -refresh-only
  • Revert the change: terraform apply (re-applies your config, overwrites the manual change)

In practice, most drift is accidental and should be reverted. The exceptions are emergency changes made during incidents — which should be codified in a follow-up PR within 24 hours.

Design Scenario: Importing a Manually-Built Database

This comes up constantly. A DBA built a production RDS instance through the console. Now you need Terraform to manage it.

# 1. Write the config to match what exists
resource "aws_db_instance" "prod" {
  identifier     = "prod-database"
  engine         = "mysql"
  engine_version = "8.0.35"
  instance_class = "db.t3.medium"
  username       = "admin"
  # ... other attributes matching the real resource
}

# 2. Import block
import {
  id = "prod-database"
  to = aws_db_instance.prod
}

Run terraform plan — it’ll show the import and any differences between your config and reality. Tweak the config until the plan shows zero changes. Then apply. From that point on, all database changes go through Terraform and Git.

The hard part isn’t the import itself — it’s writing a config that exactly matches every attribute of the existing resource. For an RDS instance, that can be 40+ attributes. Use terraform plan -generate-config-out=generated.tf if you’re on 1.6+ and save yourself an hour.

Key Takeaways

You cannot import the same resource into two different state files. If workspace A already manages a resource and you import it into workspace B, both workspaces think they own it. The next apply from either side can wreak havoc. One resource, one state file. Always.

terraform plan -refresh-only shows you what’s drifted, but it doesn’t modify state. Only terraform apply -refresh-only actually writes the refreshed state. I mention this because people run the plan, see the drift, think it’s been handled, and move on. It hasn’t.

Use the moved block and import block over their CLI equivalents whenever possible. Both are declarative, both show up in terraform plan, both are reviewable in pull requests. The CLI equivalents are for emergency state surgery — not for routine work.