TerraformX · State Operations: Read, Move, Remove, ImportProduction Terraform
moved Blocks: Declarative Refactoring
What you'll learn
- Use the moved block to declare that a resource address has changed without destroy/recreate
- Distinguish the moved block (declarative, reviewable) from state mv (imperative)
- Apply the discipline of separate PRs for moved blocks and the underlying refactor
- Remove the moved block after a successful apply
Prerequisites
None — start here.
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
terraform state mv is the imperative fix for a changed address:
run it once, log it, move on. The moved block is the declarative
fix: declare it in code, review it in the same pull request as
the configuration change, apply it through the normal plan/apply
flow. For any refactor that ships in a pull request, the moved
block is the production tool.
When moved blocks apply
Terraform 1.1 introduced the moved block. It tells Terraform
that a resource address has changed; the real-world resource has
moved from the old address to the new one; the state should be
rewritten to match.
Without moved block:
Old config: resource "aws_instance" "web"
New config: resource "aws_instance" "app"
Plan: destroy aws_instance.web, create aws_instance.app
With moved block:
moved { from = aws_instance.web, to = aws_instance.app }
Plan: (first apply) Move aws_instance.web to aws_instance.app
(subsequent plans) No changes
The moved block sits in any .tf file (commonly alongside the
new resource block). On the next plan, Terraform detects the move
and rewrites the state. After a successful apply, the block can
be removed.
The moved block syntax
moved {
from = aws_instance.web
to = aws_instance.app
}
The from and to are resource addresses. They must be valid
addresses in the configuration (the destination block must exist).
For a move into a module:
moved {
from = aws_vpc.main
to = module.network.aws_vpc.main
}
For a move between modules:
moved {
from = module.network.aws_subnet.public
to = module.network_v2.aws_subnet.public
}
For multiple moves (one block per resource):
moved {
from = aws_subnet.public[0]
to = aws_subnet.public["us-east-1a"]
}
moved {
from = aws_subnet.public[1]
to = aws_subnet.public["us-east-1b"]
}
moved {
from = aws_subnet.public[2]
to = aws_subnet.public["us-east-1c"]
}
The procedure: separate PRs
The production discipline for a rename is two pull requests, not one:
PR 1: the moved block only.
+ moved {
+ from = aws_instance.web
+ to = aws_instance.app
+ }
resource "aws_instance" "web" {
...
}
(The destination block does not yet exist. The moved block alone
is valid because moved accepts addresses not yet in
configuration as the destination, as long as the source exists in
state.)
terraform plan
# aws_instance.web: Refreshing state... [id=i-0abc...]
# aws_instance.web: Move to aws_instance.app
# Plan: 0 to add, 0 to change, 0 to destroy.
terraform apply
# Move complete.
After apply, state now records the resource at the new address
aws_instance.app. The configuration block for aws_instance.web
still exists. The next plan would propose to destroy aws_instance.web
from the cloud’s perspective — except the resource is no longer
in state under that address. This is the danger of PR 1: if a
follow-up PR removes the web block without first adding the
app block, the apply will destroy the real-world resource.
The discipline: PR 1 adds the moved block AND the new resource block. The moved block alone is too easy to misread.
PR 2: the rename.
- resource "aws_instance" "web" {
+ resource "aws_instance" "app" {
...
}
(With the moved block from PR 1 in place.)
terraform plan
# No changes. Your infrastructure matches the configuration.
The app block already exists in configuration (added in PR 1).
The state already points at the new address (moved by PR 1). The
plan sees no diff. Apply; no changes. Remove the moved block
in a follow-up PR (or in the same PR if the team prefers a single
change).
Multiple moves: the count-to-for_each case
A team is migrating a count = 3 resource to for_each with
stable string keys. Each instance needs its own moved block.
Before:
resource "aws_subnet" "public" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
}
After:
resource "aws_subnet" "public" {
for_each = toset(["us-east-1a", "us-east-1b", "us-east-1c"])
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${index(local.az_list, each.value)}.0/24"
availability_zone = each.value
}
moved {
from = aws_subnet.public[0]
to = aws_subnet.public["us-east-1a"]
}
moved {
from = aws_subnet.public[1]
to = aws_subnet.public["us-east-1b"]
}
moved {
from = aws_subnet.public[2]
to = aws_subnet.public["us-east-1c"]
}
The plan shows three moves, one per instance. Apply. Each instance is rewritten in state; no real-world change.
When moved blocks are not enough
moved rewrites the address. It does not change resource types
or attributes. For:
- Different provider: use
state replace-provider. - Different resource type: use
state rm+import. - Different module structure with renames: the
movedblock handles this case; nostate mvis needed.
Removing the moved block
After a successful apply, the moved block is no longer needed. Terraform does not error on a moved block that has already been applied (the move is one-shot), but a stale moved block in code is noise. Remove it in a follow-up commit:
- moved {
- from = aws_instance.web
- to = aws_instance.app
- }
terraform plan
# No changes. (moved block removal is not a real change.)
Validation
READ-ONLY
# Confirm the plan reflects the moved block
terraform plan
# First apply: "Move aws_instance.web to aws_instance.app"
# Subsequent: "No changes."
# Confirm the new address is in state
terraform state list | grep 'aws_instance.app'
# aws_instance.app
# Confirm the old address is not in state
terraform state list | grep 'aws_instance.web'
# (empty)
Production failure modes
Symptom: “Error: moved block refers to a resource not present in the configuration”. Cause: the source or destination address is spelled wrong, or the destination block was not added. Verify both addresses exist in configuration.
Symptom: the moved block has no effect; the plan still proposes
destroy-and-create. Cause: the moved block syntax is wrong
(missing from or to), or the addresses refer to the wrong
resources. Inspect the plan carefully.
Symptom: the moved block produces a plan to move the resource twice. Cause: a moved block from a previous PR is still in code alongside the new one. Remove the stale block.
Symptom: lock acquire failed during apply of the moved block. Cause: another apply is in progress. Wait or break the lock with team agreement.
Symptom: after the moved-block apply, the next plan proposes to destroy the old address. Cause: the new resource block was not added in the same PR. Add the destination block; re-plan; the move should already be complete in state.
Recovery
If a moved block produces unexpected behaviour, the recovery is:
- Remove the moved block from code.
terraform plan; expect the original behaviour (destroy + create if the addresses differ).- Either revert the configuration change, or add a
state mvto rewrite the state imperatively.
What comes next
The next part covers state security: the threat model, the access controls, the encryption, and the incident response for the state file.
Verification
- You can write a
movedblock that declares an address change. - You can describe the two-PR discipline: moved block plus new block first, then remove the old block.
- You can spot a stale moved block that has already been applied.
- You can recover from a wrong moved block by removing it and planning again.
Knowledge check · 7 questions
Q1. When was the moved block introduced?
Q2. What is the production discipline for shipping a moved block?
Q3. After a successful apply, the moved block must be removed from code immediately or the next apply will fail.
Q4. Which is the right tool for migrating a `count = 3` resource to `for_each` with stable string keys?
Q5. Which of these are valid uses for the moved block? (Select all that apply.)
Q6. A team adds a moved block but the plan still proposes to destroy the old address and create the new one. What is the most likely cause?
Q7. A team renames aws_db_instance.primary to aws_db_instance.main in a configuration PR. They want the change to be reviewable. Which is the right workflow?
Passing score: 75%. Answers are checked in this browser.