Terraform Import, Moved Blocks & State Manipulation
Importing existing infrastructure, safely renaming resources with moved blocks, and manipulating state directly are critical operational skills. The Terraform Associate exam tests the CLI import workflow, the config-driven import block (v1.5+), moved block patterns for refactoring, all terraform state subcommands, -replace vs taint, -refresh-only for drift detection, and force-unlock.
1. Why Import & State Manipulation?
Three real-world scenarios drive the need for these tools:
2. terraform import — CLI Command
The original import mechanism. Writes to state but does not generate HCL — you must write the config manually first:
1# Syntax
2terraform import <RESOURCE_ADDRESS> <REAL_WORLD_ID>
3
4# Common examples
5terraform import aws_instance.web i-0abcd1234ef567890
6terraform import aws_s3_bucket.logs my-company-access-logs
7terraform import aws_security_group.app sg-0a1b2c3d4e5f67890
8terraform import aws_vpc.main vpc-0123456789abcdef0
9terraform import aws_iam_role.lambda my-lambda-execution-role
10terraform import aws_db_instance.postgres prod-postgres-db
11terraform import aws_route53_record.www Z1D633PJN98FT9_www.example.com_A
12
13# For_each resources — include the key in brackets
14terraform import 'aws_instance.servers["web"]' i-0aaa111bbb222ccc
15terraform import 'aws_instance.servers["app"]' i-0ddd333eee444fff
16
17# Module resources — include full module path
18terraform import module.compute.aws_instance.web i-0abcd1234ef567890The full CLI import workflow:
1# Step 1: Write the resource block (minimal — just enough to import)
2resource "aws_instance" "web" {
3 # Leave empty or add a few known attributes
4 # Terraform will populate state from real infrastructure
5}1# Step 2: Run import
2terraform import aws_instance.web i-0abcd1234ef567890
3# Import successful. The resources that were imported are shown above.
4# These resources are now in your Terraform state and will henceforth
5# be managed by Terraform.
6
7# Step 3: See what config changes are needed
8terraform plan
9# Will show all attributes that differ between config and state
10# Add each one to your resource block until plan shows "No changes."Finding the real-world ID to use with import:
| Resource Type | ID Format | How to Find |
|---|---|---|
aws_instance | i-0abcd1234 | EC2 console → Instance ID |
aws_s3_bucket | Bucket name | S3 console → Bucket name |
aws_security_group | sg-0abc123 | EC2 → Security Groups |
aws_vpc | vpc-0abc123 | VPC console |
aws_iam_role | Role name (not ARN) | IAM console → Role name |
aws_route53_record | ZONE_ID_NAME_TYPE | Route53 + record details |
aws_db_instance | DB identifier | RDS console → DB identifier |
3. import Block — Config-Driven Import (Terraform 1.5+)
The modern approach: declare imports as code inside .tf files. Reviewable, repeatable, works in CI/CD:
1# import.tf — declare all imports here
2
3import {
4 to = aws_instance.web
5 id = "i-0abcd1234ef567890"
6}
7
8import {
9 to = aws_s3_bucket.logs
10 id = "my-company-access-logs"
11}
12
13import {
14 to = aws_security_group.app
15 id = "sg-0a1b2c3d4e5f67890"
16}
17
18# Import into a module resource
19import {
20 to = module.network.aws_vpc.main
21 id = "vpc-0123456789abcdef0"
22}1# The corresponding resource blocks (must exist)
2resource "aws_instance" "web" {
3 ami = "ami-0c55b159cbfafe1f0"
4 instance_type = "t3.micro"
5 # other attributes...
6}Auto-generate config with -generate-config-out (Terraform 1.5+):
1# Write import blocks, then let Terraform generate the resource HCL
2terraform plan -generate-config-out=generated_resources.tf
3
4# Terraform writes a complete resource block to generated_resources.tf
5# with all attributes filled in from the real infrastructure
6
7# Review and clean up generated_resources.tf, then apply
8terraform apply1# Example of what terraform generates in generated_resources.tf:
2resource "aws_instance" "web" {
3 ami = "ami-0c55b159cbfafe1f0"
4 instance_type = "t3.micro"
5 availability_zone = "us-east-1a"
6 subnet_id = "subnet-0abc123"
7 vpc_security_group_ids = ["sg-0abc123"]
8 key_name = "my-key-pair"
9 associate_public_ip_address = true
10 # ... many more attributes
11}import block with for_each (Terraform 1.7+):
1# Import multiple resources at once using for_each
2locals {
3 servers_to_import = {
4 web = "i-0aaa111bbb222ccc3"
5 app = "i-0ddd333eee444fff5"
6 db = "i-0ggg555hhh666iii7"
7 }
8}
9
10import {
11 for_each = local.servers_to_import
12 to = aws_instance.servers[each.key]
13 id = each.value
14}
15
16resource "aws_instance" "servers" {
17 for_each = local.servers_to_import
18 ami = data.aws_ami.ubuntu.id
19 instance_type = "t3.micro"
20}CLI import vs import block:
| Aspect | terraform import CLI | import {} block |
|---|---|---|
| Version | All versions | Terraform >= 1.5 |
| Code reviewable? | No | Yes |
| Repeatable? | Manual each time | Runs with terraform apply |
| Generates HCL? | No | Yes (with -generate-config-out) |
| For_each support? | One at a time | Yes (>= 1.7) |
| CI/CD friendly? | No | Yes |
| Preferred approach? | Legacy | Modern standard |
4. moved Block — Rename Without Destroy
When you rename a resource or move it into/out of a module, Terraform sees a new address and plans to destroy the old + create the new. The moved block tells Terraform these are the same resource:
1# Scenario 1: Simple resource rename
2# BEFORE: resource "aws_instance" "web_server"
3# AFTER: resource "aws_instance" "app_server"
4
5moved {
6 from = aws_instance.web_server
7 to = aws_instance.app_server
8}
9
10resource "aws_instance" "app_server" {
11 ami = data.aws_ami.ubuntu.id
12 instance_type = "t3.micro"
13}1# Scenario 2: Moving a resource into a module
2# BEFORE: resource "aws_instance" "web" (in root)
3# AFTER: module "compute" → aws_instance.web
4
5moved {
6 from = aws_instance.web
7 to = module.compute.aws_instance.web
8}1# Scenario 3: Moving out of a module (module refactor)
2moved {
3 from = module.old_vpc.aws_vpc.main
4 to = aws_vpc.main
5}1# Scenario 4: Renaming a module itself
2moved {
3 from = module.server
4 to = module.web_server
5}1# Scenario 5: Moving indexed (count) to for_each
2# BEFORE: aws_instance.web[0], web[1], web[2]
3# AFTER: aws_instance.web["frontend"], ["backend"], ["worker"]
4moved {
5 from = aws_instance.web[0]
6 to = aws_instance.web["frontend"]
7}
8moved {
9 from = aws_instance.web[1]
10 to = aws_instance.web["backend"]
11}
12moved {
13 from = aws_instance.web[2]
14 to = aws_instance.web["worker"]
15}moved block rules:
- No real infrastructure changes — only state is updated
terraform planshows the move:aws_instance.web_server has moved to aws_instance.app_server- After
terraform apply, the moved block can be removed from config - Keep moved blocks temporarily to allow teammates to apply cleanly before removing
- Can be declared in any
.tffile —moved.tfby convention
moved block vs terraform state mv:
| Aspect | moved {} block | terraform state mv |
|---|---|---|
| In version control | Yes | No |
| Code reviewed | Yes | No |
| Auditable | Yes — git history | No |
| Undoable | Yes — remove the block | Manual reverse command |
| Works in CI/CD | Yes | Requires manual intervention |
| Preferred approach | Yes (modern) | Legacy |
5. All terraform state Subcommands
1# ── READ OPERATIONS (safe to run anytime) ──────────────────────────
2
3# List all tracked resources
4terraform state list
5
6# Filter by type, name, or module
7terraform state list aws_instance.web
8terraform state list 'module.vpc.*'
9terraform state list 'aws_instance.*'
10
11# Show full attributes of a resource (useful after import)
12terraform state show aws_instance.web
13terraform state show 'module.compute.aws_instance.web'
14
15# Download remote state as JSON (ALWAYS do this before any write ops)
16terraform state pull
17terraform state pull > backup_$(date +%Y%m%d_%H%M%S).tfstate
18
19
20# ── WRITE OPERATIONS (destructive — back up first!) ────────────────
21
22# Rename/move a resource in state (legacy — prefer moved block)
23terraform state mv aws_instance.web aws_instance.app_server
24terraform state mv aws_instance.web 'module.compute.aws_instance.web'
25terraform state mv 'module.old' 'module.new'
26
27# Remove a resource from state WITHOUT destroying real infra
28# Use when: stopping management, resource deleted out-of-band
29terraform state rm aws_instance.web
30terraform state rm 'module.legacy.aws_s3_bucket.data'
31terraform state rm 'aws_instance.servers["web"]'
32
33# Upload local state to remote backend
34# WARNING: overwrites remote — only use for emergency recovery
35terraform state pull > emergency_backup.tfstate
36terraform state push recovered.tfstateterraform state rm use cases:
| Scenario | Command |
|---|---|
| Stop managing a resource (keep it running) | terraform state rm aws_instance.legacy |
| Resource was manually deleted (fix plan errors) | terraform state rm aws_s3_bucket.deleted |
| Moving resource to another Terraform config | state rm in source, then import in destination |
| Emergency: remove corrupted resource from state | state rm then re-import |
6. Replacing Resources — -replace and taint
Force Terraform to destroy and recreate a specific resource on the next apply:
1# ── MODERN: -replace flag (Terraform 0.15.2+) ──────────────────────
2
3# Preview what will be replaced
4terraform plan -replace="aws_instance.web"
5
6# Apply the replacement
7terraform apply -replace="aws_instance.web"
8
9# Multiple resources at once
10terraform apply -replace="aws_instance.web" -replace="aws_lb.frontend"
11
12
13# ── LEGACY: terraform taint (deprecated in 0.15.2) ─────────────────
14
15# Old way — marks resource as tainted in state
16terraform taint aws_instance.web
17terraform apply # tainted resources are destroyed+recreated
18
19# Remove a taint mark without replacing
20terraform untaint aws_instance.webWhen to use -replace:
- Instance is misbehaving but config is correct
- AMI has been updated and you want a fresh instance
- Corrupted application state on the instance
- Force certificate rotation on a TLS resource
Automatic replacement via lifecycle:
1resource "aws_instance" "web" {
2 ami = data.aws_ami.ubuntu.id
3 instance_type = "t3.micro"
4
5 lifecycle {
6 # Replace whenever the launch template version changes
7 replace_triggered_by = [aws_launch_template.web.latest_version]
8 }
9}7. Drift Detection — refresh-only
Drift occurs when real infrastructure changes outside Terraform (manual console edits, other tools). The -refresh-only flag detects and optionally accepts this drift:
1# Detect drift — show what changed in real infra vs state
2# Does NOT modify state or real infra — safe to run anytime
3terraform plan -refresh-only
4
5# Example output when drift detected:
6# ~ aws_instance.web
7# ~ tags = {
8# + "LastModified" = "2024-01-15" # someone added a tag manually
9# }
10
11# Accept drift — update state to match real infra
12# Does NOT apply your config changes
13terraform apply -refresh-onlyDrift workflow options:
1# Option A: Accept drift (real infra is correct, update state)
2terraform apply -refresh-only
3
4# Option B: Reject drift (revert real infra to match config)
5terraform apply # standard apply overwrites manual changes
6
7# Option C: Investigate without changing anything
8terraform plan -refresh-only # read-only drift report| Command | Updates State? | Updates Real Infra? |
|---|---|---|
terraform plan -refresh-only | No | No |
terraform apply -refresh-only | Yes | No |
terraform plan | No | No |
terraform apply | Yes | Yes |
terraform refresh (deprecated) | Yes | No |
8. force-unlock — Breaking a Stuck Lock
If a plan or apply is interrupted, the state lock may not be released:
1# Error you might see:
2# Error: Error acquiring the state lock
3# Lock Info:
4# ID: abc12345-...
5# Operation: OperationTypeApply
6# Who: engineer@example.com
7# Created: 2024-01-15 10:23:45 UTC
8
9# Release the lock manually
10terraform force-unlock abc12345-...
11
12# Force unlock bypassing confirmation prompt
13terraform force-unlock -force abc12345-...Warning: Only use
force-unlockwhen you are certain the original process is dead. If the originalterraform applyis still running and you force-unlock, both processes will modify state simultaneously — causing corruption. Always verify the original process is dead before unlocking.
9. Complete Import Workflow — Real Example
Scenario: An S3 bucket, IAM role, and EC2 instance were created manually. Bring them all under Terraform management:
1# Step 1: Write import blocks (import.tf)
2import {
3 to = aws_s3_bucket.app_data
4 id = "prod-app-data-bucket-2024"
5}
6
7import {
8 to = aws_iam_role.app_role
9 id = "prod-app-execution-role"
10}
11
12import {
13 to = aws_instance.app
14 id = "i-0abcd1234ef567890"
15}1# Step 2: Generate HCL config for all three resources
2terraform plan -generate-config-out=imported_resources.tf
3# Creates imported_resources.tf with full resource definitions
4
5# Step 3: Review and clean up generated config
6# Remove read-only attributes (id, arn, etc.)
7# Remove attributes you don't want to manage
8# Fix any Terraform-specific formatting issues
9
10# Step 4: Validate the import will work cleanly
11terraform plan
12# Should show: Plan: 3 to import, 0 to add, 0 to change, 0 to destroy
13
14# Step 5: Apply the import
15terraform apply
16# Imports resources into state without modifying real infrastructure
17
18# Step 6: Remove import blocks (they've served their purpose)
19# Delete the contents of import.tf
20
21# Step 7: Verify
22terraform plan
23# Should show: No changes. Your infrastructure matches the configuration.10. Complete Refactoring Workflow — Moving Into a Module
Scenario: Refactor a VPC resource from the root module into a network child module:
1# Before refactoring — state has:
2# aws_vpc.main
3# aws_subnet.public[0]
4# aws_subnet.public[1]
5# aws_internet_gateway.main1# Step 1: Create the module (modules/network/main.tf)
2resource "aws_vpc" "main" { ... }
3resource "aws_subnet" "public" { count = 2 ... }
4resource "aws_internet_gateway" "main" { ... }
5
6# Step 2: Add moved blocks to root main.tf
7moved {
8 from = aws_vpc.main
9 to = module.network.aws_vpc.main
10}
11
12moved {
13 from = aws_subnet.public[0]
14 to = module.network.aws_subnet.public[0]
15}
16
17moved {
18 from = aws_subnet.public[1]
19 to = module.network.aws_subnet.public[1]
20}
21
22moved {
23 from = aws_internet_gateway.main
24 to = module.network.aws_internet_gateway.main
25}
26
27# Step 3: Call the module in root main.tf
28module "network" {
29 source = "./modules/network"
30 vpc_cidr = var.vpc_cidr
31 # ...
32}1# Step 4: Plan — should show only moves, no destroy/create
2terraform plan
3# Output:
4# Terraform will perform the following actions:
5# aws_vpc.main has moved to module.network.aws_vpc.main
6# aws_subnet.public[0] has moved to module.network.aws_subnet.public[0]
7# ...
8# Plan: 0 to add, 0 to change, 0 to destroy.
9# (4 moves)
10
11# Step 5: Apply — only state is updated, real infra unchanged
12terraform apply
13
14# Step 6: Remove moved blocks after team has applied11. Quick Reference
| Operation | Command / Block | Notes |
|---|---|---|
| Import (legacy) | terraform import ADDR ID | CLI only; no HCL generated |
| Import (modern) | import { to = ... id = ... } | Config-driven; Terraform >= 1.5 |
| Generate HCL on import | terraform plan -generate-config-out=file.tf | Terraform >= 1.5 |
| Bulk import | import { for_each = ... } | Terraform >= 1.7 |
| Rename without destroy | moved { from = ... to = ... } | No infra change; state only |
| Move into module | moved { from = resource to = module.x.resource } | |
| List state | terraform state list | Read-only; safe |
| Inspect resource | terraform state show ADDR | Read-only; safe |
| Rename in state (legacy) | terraform state mv SRC DST | Prefer moved block |
| Remove from state | terraform state rm ADDR | Does NOT destroy real infra |
| Backup state | terraform state pull > backup.tfstate | Always do before write ops |
| Force replace | terraform apply -replace=ADDR | Destroy + recreate |
| Taint (deprecated) | terraform taint ADDR | Use -replace instead |
| Accept drift | terraform apply -refresh-only | Updates state only |
| Detect drift | terraform plan -refresh-only | Read-only diff |
| Release stuck lock | terraform force-unlock LOCK_ID | Verify original process is dead first |