Every Terraform user eventually hits the same wall. You rename aws_instance.web to aws_instance.web_server because the old name was sloppy, run terraform plan, and Terraform cheerfully proposes to destroy your production instance and create a brand-new one. Nothing about the infrastructure changed. Only the address changed. But Terraform tracks resources by address, so a rename looks exactly like “delete the old thing, create a new thing”.
For years the answer was imperative state surgery: terraform state mv on a laptop, hopefully against the right backend, hopefully with a backup. Modern Terraform gives you two declarative blocks instead. The moved block tells Terraform that an object changed address. The removed block tells Terraform to stop managing an object, optionally without destroying it. Both live in your configuration, both are previewed by terraform plan, and both go through code review like any other change.
The HashiCorp Terraform Associate (004) exam objective 6d, “Manage resource drift and Terraform state”, explicitly points candidates to the moved block and removed block references. The sibling guide on importing existing infrastructure and detecting drift introduces moved in a single section alongside import blocks. This article goes deeper on both refactoring blocks: the exact behaviours, the plan output you should expect, the module and count/for_each cases, the restrictions, and the decision table that separates them from the terraform state subcommands covered in the state management guide.
Why Address Changes Look Like Destroy-and-Recreate
Terraform’s state file is a map from resource addresses to real-world object IDs. aws_instance.web maps to i-0abc123.... When Terraform plans, it compares the set of addresses in your configuration to the set of addresses in state:
- An address in configuration but not in state means create.
- An address in state but not in configuration means destroy.
- An address in both means compare attributes and update in place or replace.
Rename aws_instance.web to aws_instance.web_server and you have created one of each. Terraform has no way of knowing the two addresses refer to the same EC2 instance. The HashiCorp documentation states it plainly: “By default, Terraform interprets a change as an instruction to destroy the existing resource and create a new resource at the new address.”
The same mechanics apply to every address change, not only renames:
- Adding
countorfor_eachto a resource that previously had neither (the address gains an index key). - Moving a resource into or out of a child module (the address gains or loses a
module.name.prefix). - Renaming a module call.
- Splitting one module into two.
All of these are refactors, not infrastructure changes, and all of them need either a moved block or an imperative terraform state mv.
The moved Block
Syntax
A moved block is a top-level block with exactly two arguments:
moved {
from = aws_instance.web
to = aws_instance.web_server
}
resource "aws_instance" "web_server" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
}
from is the previous address and to is the new address. Both are bare resource addresses, not quoted strings. Note that the old resource "aws_instance" "web" block is gone, replaced by the new one. The moved block is what bridges the two.
When you run terraform plan, Terraform looks in state for an object at the from address. If it finds one, it records the object under the to address instead. The plan output tells you precisely what will happen:
Terraform will perform the following actions:
# aws_instance.web has moved to aws_instance.web_server
resource "aws_instance" "web_server" {
id = "i-0abc123def4567890"
# (unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.
“0 to add, 0 to change, 0 to destroy” is the whole point. If you see a destroy in a plan that was supposed to be a pure rename, your moved block is missing or has a typo in an address.
moved blocks require Terraform 1.1 or later. On older versions the only option is terraform state mv.
Enabling count or for_each on an Existing Resource
This is the case that catches people in real projects and on the exam. Suppose you had a single bucket and you now want three, driven by for_each:
resource "aws_s3_bucket" "logs" {
for_each = toset(["app", "audit", "alb"])
bucket = "acme-${each.key}-logs"
}
moved {
from = aws_s3_bucket.logs
to = aws_s3_bucket.logs["app"]
}
The existing bucket becomes the "app" instance; the other two are created. Without the moved block Terraform would destroy the existing bucket (a bucket with data in it) and create three new ones.
The same pattern works for count. A single resource becomes index zero:
moved {
from = aws_instance.worker
to = aws_instance.worker[0]
}
And you can migrate between the two meta-arguments, mapping each numeric index to a string key:
moved {
from = aws_instance.worker[0]
to = aws_instance.worker["small"]
}
moved {
from = aws_instance.worker[1]
to = aws_instance.worker["large"]
}
If you are weighing the two meta-arguments in the first place, the count vs for_each guide explains why for_each with stable keys avoids the index-shifting problem that makes count refactors painful.
Moving Resources Into and Out of Modules
Extracting a resource into a reusable module changes its address from aws_instance.app to module.compute.aws_instance.app. Declare the move in the root module, where both the old and new addresses are visible:
module "compute" {
source = "./modules/compute"
}
moved {
from = aws_instance.app
to = module.compute.aws_instance.app
}
Renaming a module call follows the same shape, and so does adding count or for_each to a module:
moved {
from = module.network
to = module.vpc
}
moved {
from = module.vpc
to = module.vpc["prod"]
}
When a module author splits one module into several, the documentation recommends keeping the original module as a thin shim that contains only moved blocks, so existing callers upgrade without destroying anything. The modules guide covers module versioning conventions that make this upgrade path predictable.
Chained Moves and Historical moved Blocks
If an object moves twice over the life of a module, chain the blocks rather than editing the first one:
moved {
from = aws_instance.a
to = aws_instance.b
}
moved {
from = aws_instance.b
to = aws_instance.c
}
Terraform follows the chain, so a user upgrading from the version that had aws_instance.a lands on aws_instance.c in one plan. HashiCorp’s documentation is explicit about the consequences of deleting old blocks: “Removing a moved block is a breaking change because any configurations that refer to the old address will plan to delete the existing object instead of move it.” For published or shared modules, the guidance is to retain historical moved blocks indefinitely. Only a private module whose every consumer is confirmed to be on the new version can safely drop them.
Restrictions the Exam Likes
| Attempted move | Allowed? |
|---|---|
| Rename a resource of the same type | Yes |
Add or remove count / for_each (with index keys) | Yes |
| Move into, out of, or between modules in the same configuration | Yes |
Rename a module call or add count / for_each to it | Yes |
Change a resource into a data block | No |
| Move between different resource types | No (in general) |
| Move an object to an address that already has an object in state | No, Terraform errors |
Apply a moved block to an object that does not exist in state | Nothing happens, no error |
That last row is important and under-appreciated. A moved block whose from address is absent from state is a no-op. That is exactly what makes historical moved blocks safe to keep: for a fresh workspace there is nothing to move, so Terraform ignores them.
The removed Block
Syntax and the destroy Decision
A removed block declares that Terraform should stop managing an object. Its lifecycle block decides whether the real object is destroyed in the process:
removed {
from = aws_s3_bucket.legacy_assets
lifecycle {
destroy = false
}
}
With destroy = false, Terraform forgets the object: it is dropped from state but left running, and Terraform will never touch it again. This is the declarative equivalent of terraform state rm. With destroy = true (the default behaviour), Terraform destroys the object and then drops it from state, which is simply the normal behaviour you get by deleting a resource block, but made explicit and reviewable.
The plan output for a forget is unmistakable:
Terraform will perform the following actions:
# aws_s3_bucket.legacy_assets will no longer be managed by Terraform, but will not be destroyed
# (destroy = false is set in the configuration)
. resource "aws_s3_bucket" "legacy_assets" {
id = "acme-legacy-assets"
# (unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.
removed blocks were introduced in Terraform 1.7 (January 2024). As with moved, the from address is unquoted.
The Workflow
- Delete the
resourceblock for the object. If you leave theresourceblock in place alongside aremovedblock for the same address, Terraform refuses to plan with a “Removed resource still exists” error. The two blocks are mutually exclusive. - Add the
removedblock with the appropriatelifecyclesetting. - Remove every reference to the old resource’s attributes elsewhere in the configuration, or those references fail to resolve.
- Run
terraform plan, confirm the “will no longer be managed” line and the zero-destroy summary, thenterraform apply. - Once applied, you may delete the
removedblock. Unlikemoved, there is no upgrade-path reason to keep it, because the object is simply gone from state.
Removing Modules and Multi-Instance Resources
The from address can be a whole module:
removed {
from = module.legacy_monitoring
lifecycle {
destroy = false
}
}
Every resource inside that module instance is forgotten (or destroyed) together.
What you cannot do is name individual instances. The documentation is categorical: “You cannot include instance keys, such as aws_instance.example[1], if the resource is configured to provision multiple instances.” A removed block always acts on all instances of the address. To forget only aws_instance.example[1] you still need terraform state rm 'aws_instance.example[1]', and this is one of the few remaining cases where the imperative command is the only answer.
Destroy-Time Provisioners in removed Blocks
Deleting a resource block also deletes any provisioner blocks it contained, including when = destroy provisioners that were supposed to run clean-up at destroy time. Terraform 1.9 solved this by allowing provisioners inside removed blocks:
removed {
from = aws_instance.batch_node
lifecycle {
destroy = true
}
connection {
type = "ssh"
host = self.public_ip
user = "ec2-user"
}
provisioner "remote-exec" {
when = destroy
inline = ["sudo /opt/batch/drain-and-deregister.sh"]
}
}
Only destroy-time provisioners are supported here, so the when = destroy argument is mandatory. The lifecycle meta-arguments and provisioners guide explains why provisioners are a last resort in the first place, but when you do have one, a removed block is how you keep it alive through the resource’s retirement.
Declarative Blocks vs the terraform state Subcommands
The exam expects you to know both families and to pick the right one for a scenario.
| Need | Declarative (configuration) | Imperative (CLI) |
|---|---|---|
| Rename or relocate a managed object | moved block | terraform state mv |
| Stop managing an object, keep it running | removed block with destroy = false | terraform state rm |
| Destroy an object and stop managing it | removed block (default) or delete the resource block | terraform destroy -target=... then remove config |
| Adopt an existing object | import block | terraform import |
| Force re-creation of a healthy object | (none) | terraform apply -replace=... |
| Act on one instance of a multi-instance resource | moved yes, removed no | terraform state mv / rm with the index key |
| Minimum Terraform version | 1.1 (moved), 1.5 (import), 1.7 (removed) | Any |
The declarative blocks win on four counts that the exam phrases in different ways:
- Previewable. The change shows up in
terraform planbefore anything happens.terraform state mvandstate rmedit state immediately (each does offer a-dry-runflag, but nobody is forced to use it). - Reviewable. They are code, so they go through pull requests and pass through the same CI pipeline as everything else. State commands are run by one person on one machine.
- Repeatable across workspaces. One
movedblock in a shared module upgrades every workspace that uses the module. A state command must be re-run per workspace, per environment. - Self-documenting. A chain of
movedblocks is a history of the module’s structure.
The imperative commands remain the right answer when you need per-instance precision, when you are on a Terraform version that predates the block you need, or when you are repairing state after an accident rather than performing a planned refactor. The Terraform commands cheat sheet lists the state subcommand flags if you need a refresher.
A Worked Refactor: Flat Configuration to Modules
Here is an end-to-end example that uses both blocks in a single change. A flat root module has a VPC, two subnets created with count, and an old bastion host that has been replaced by Session Manager and should be handed to another team’s Terraform rather than destroyed.
Before:
resource "aws_vpc" "main" { /* ... */ }
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
/* ... */
}
resource "aws_instance" "bastion" { /* ... */ }
After, in the root module:
module "network" {
source = "./modules/network"
azs = { a = "us-east-1a", b = "us-east-1b" }
}
# VPC moves into the module unchanged.
moved {
from = aws_vpc.main
to = module.network.aws_vpc.this
}
# Subnets move into the module AND switch from count to for_each.
moved {
from = aws_subnet.private[0]
to = module.network.aws_subnet.private["a"]
}
moved {
from = aws_subnet.private[1]
to = module.network.aws_subnet.private["b"]
}
# Bastion leaves this state file but keeps running.
removed {
from = aws_instance.bastion
lifecycle {
destroy = false
}
}
A correct plan for this change reads: three “has moved to” lines, one “will no longer be managed by Terraform, but will not be destroyed” line, and Plan: 0 to add, 0 to change, 0 to destroy. Anything else means a mistake, and because it is a plan rather than a state mv, you get to fix the mistake before it costs anything. The other team can then adopt the bastion with an import block in their own configuration, which is where this story connects back to the import workflow.
Run this through the standard init, plan, apply workflow with a saved plan file (terraform plan -out=refactor.tfplan then terraform apply refactor.tfplan) so that the apply is guaranteed to execute exactly the plan you reviewed.
How moved and removed Show Up on the Exam
Objective 6d is the home for both blocks, but questions arrive dressed as scenarios rather than definitions. Patterns worth recognising:
- “A team renamed a resource and the plan shows one destroy and one create. What is the least disruptive fix?” Add a
movedblock. Notterraform import, not-replace, and notstate rmfollowed byimport. - “Terraform should stop managing a database but the database must keep running.” A
removedblock withdestroy = false, orterraform state rm. Deleting theresourceblock alone destroys the database. - “Which Terraform version is required for
moved?” 1.1. Forremoved, 1.7. Forimportblocks, 1.5. Watch for distractors that put the features in the wrong release. - “A module author deleted old
movedblocks and downstream users saw destroys.” Removing historicalmovedblocks is a breaking change. - “Remove only the second instance of a
countresource from state.”removedblocks cannot target instance keys; useterraform state rmwith the index. - “What does the plan summary show for a successful move?”
0 to add, 0 to change, 0 to destroy.
The Terraform Associate exam guide maps every objective to the depth you need, and the free Terraform Associate practice questions include state-management scenarios in this style. For a timed, exam-weighted rehearsal that includes refactoring and state scenarios across all nine objectives, the HashiCorp Terraform Associate (004) Mock Exam Bundle is built to show you which of these distinctions you still confuse under time pressure.
Practical Guidance and Gotchas
- Always plan a refactor in isolation. Mix a
movedblock with a real attribute change and a mistake in the move is harder to spot among legitimate changes. One pull request for the move, another for the behaviour change. - Use
-outplan files for refactors. It removes any possibility of the apply differing from the reviewed plan. - Addresses are unquoted inside the blocks.
from = aws_instance.web, notfrom = "aws_instance.web". The CLI commands, by contrast, take quoted strings and need shell quoting for index keys. - Remove attribute references first. A
removedresource that is still referenced by another resource’s arguments produces an error before the forget can happen. movedinto a module requires the module to declare the resource. Thetoaddress must exist in the new configuration, or Terraform plans a destroy of the old address with nothing to move it to.- Forgetting is not deleting. After
destroy = false, the object still exists and still costs money. Somebody has to own it: another workspace, another tool, or a human. - Check your provider’s limits. Some resource types cannot be imported after being forgotten, which makes the forget effectively irreversible from Terraform’s point of view. Confirm the import path exists before you remove.
Conclusion
moved and removed blocks turn two of Terraform’s historically most dangerous operations, renaming and un-managing, into ordinary code changes with an ordinary plan. moved handles every address change: renames, count and for_each migrations, module extractions, and module renames, with chained blocks preserving the full history for downstream users. removed handles retirement, with a single destroy flag deciding whether the object dies or merely leaves state, and since Terraform 1.9 it can carry the destroy-time provisioners that used to vanish with the resource block.
For the Terraform Associate (004) exam, remember the version numbers (1.1, 1.5, 1.7), the “0 to add, 0 to change, 0 to destroy” signature of a correct move, the fact that removed cannot address individual instances, and the warning that deleting historical moved blocks breaks consumers. Keep terraform state mv and terraform state rm in your toolkit for the per-instance and emergency cases, and reach for the blocks for everything else.
Frequently Asked Questions
What is the difference between a moved block and terraform state mv?
Both change the address under which Terraform tracks an object. A moved block is declared in configuration, previewed by terraform plan, reviewed in version control, and applied automatically in every workspace that uses the configuration. terraform state mv is an imperative CLI command that edits one state file immediately. HashiCorp recommends moved blocks for all planned refactors on Terraform 1.1 and later, and reserves state mv for older versions and for one-off repairs.
Does a removed block destroy the resource?
By default, yes: Terraform destroys the object and drops it from state, which matches what happens when you simply delete a resource block. Set lifecycle { destroy = false } inside the removed block to drop the object from state while leaving the real infrastructure running. The plan will say the resource “will no longer be managed by Terraform, but will not be destroyed”.
Can I use a removed block for just one instance of a resource with count or for_each?
No. A removed block applies to every instance of the address and the documentation states that instance keys such as aws_instance.example[1] are not allowed in from. To remove a single instance from state, use terraform state rm with the full indexed address.
Which Terraform versions introduced moved, import and removed blocks?
moved blocks arrived in Terraform 1.1, import blocks in Terraform 1.5, and removed blocks in Terraform 1.7. Destroy-time provisioners inside removed blocks were added in Terraform 1.9. These version numbers are a favourite source of exam distractors.
Can I delete a moved block after it has been applied?
Technically yes, but for any module used by more than one workspace it is a breaking change: a consumer that has not yet applied the move would plan to destroy the object at the old address and create a new one. HashiCorp strongly recommends retaining all historical moved blocks. A moved block whose from address is not in state is a no-op, so keeping them is free.
What happens if I keep the resource block and add a removed block for the same address?
Terraform reports an error that the removed resource still exists in configuration and refuses to plan. You must delete the resource block (and any references to its attributes) before the removed block can take effect.
Can a moved block change a resource’s type?
No. moved blocks relocate an object between addresses of the same resource type, and they cannot convert a resource into a data source. Changing the type of an object means destroying it and creating a different kind of object, which is not a move.