The whole construct, for a resource whose label changed from db to database:
moved {
from = aws_db_instance.db
to = aws_db_instance.database
}Two addresses, unquoted, written in the module whose addresses they are. Put it beside the renamed resource, run terraform plan, and the plan reports the move and plans nothing. The rest of this page is why that works, where the block goes in the cases that are not a straight rename, and how long to leave it in place.
What a moved block is
It is a block you write in your Terraform configuration, alongside the resource it is about. It takes exactly two arguments, from and to, and each one is a resource address rather than a string. Terraform reads it during the next plan and rewrites the state entry in place instead of planning a destroy and a create.
It arrived in Terraform 1.1 and OpenTofu carries it too. Before that, the only way to do this was terraform state mv, run by hand, by one person, against one state file.
The important property is that it is configuration. It is reviewed in the pull request beside the rename it belongs to, it is versioned with that change, and it runs for everyone who applies afterwards, including CI and every other workspace. Nobody has to be told to run a command.
Why Terraform destroys a renamed resource
Terraform's state is a mapping from an address to one real remote object. The address is the resource type and the label you chose: aws_db_instance.db. Nothing else in that state entry ties it to your configuration.
So when you rename the label, two things happen at once:
- the old address is in the state but in no configuration, which Terraform plans as a destroy
- the new address is in the configuration but in no state, which Terraform plans as a create
Terraform is not comparing the two names and deciding they are unrelated. It has no way to tell a rename apart from a deletion plus an unrelated addition, because the address is the only identity in the entry. So take this resource:
resource "aws_db_instance" "db" {
identifier = "billing"
instance_class = "db.t3.medium"
}change nothing about it except the label, from db to database, and the plan says this:
# aws_db_instance.db will be destroyed
# (because aws_db_instance.db is not in configuration)
- resource "aws_db_instance" "db" {
...
}
# aws_db_instance.database will be created
+ resource "aws_db_instance" "database" {
...
}
Plan: 1 to add, 0 to change, 1 to destroy.On a subnet that is an outage. On a database it is the data. And the two stanzas are hundreds of lines apart in a real plan, in the middle of everything else the change does, which is exactly why this gets approved.
How to write one
Rename the resource, update every reference to it, and add the block:
resource "aws_db_instance" "database" {
identifier = "billing"
instance_class = "db.t3.medium"
}
moved {
from = aws_db_instance.db
to = aws_db_instance.database
}from and to take addresses, unquoted, exactly as you would write them anywhere else in Terraform. A moved block takes nothing else: no count, no for_each, no variables and no expressions. The addresses are literal, which is occasionally inconvenient and is what makes the block readable as a record of what happened.
Where the block goes
In the module whose addresses it names. A rename inside one module goes in that module. A move into or out of a child module goes in the parent that calls it, because the parent is the only place where both addresses can be written at all.
Check the plan before you apply it
This is the step people skip, and it is the only one that answers the question they actually have. Terraform accepting the block means the syntax is valid. It does not mean the destroy has gone, and a block with a typo in an address is valid configuration that does nothing at all.
What a correct plan looks like
The move is reported, and nothing is planned. No symbols, no resource bodies, and a summary of zeroes:
# aws_db_instance.db has moved to aws_db_instance.database
resource "aws_db_instance" "database" {
...
}
Plan: 0 to add, 0 to change, 0 to destroy.If the same change also edited an attribute, an update in place alongside the move is fine and expected. What must not be in that plan is a destroy.
What a still-wrong plan looks like
The destroy is still there, usually with its matching create hundreds of lines away, and the block might as well not exist:
# aws_db_instance.db will be destroyed
# (because aws_db_instance.db is not in configuration)
- resource "aws_db_instance" "db" {
...
}
Plan: 1 to add, 0 to change, 1 to destroy.A from address that is not in the state does nothing and reports nothing, which is what makes this quiet. Terraform has no way to tell an address you mistyped from an address somebody else already applied the move for.
What to do if a destroy is still there
- Copy both addresses out of the plan. Do not retype them. The plan prints the exact address Terraform holds in state, and the plan prints the exact address your configuration declares. The block has to name those two and nothing else.
- Check the instance key. If the resource has
countorfor_each, the key is part of the address and the block needs one entry per instance.this[0]andthis["app"]are different objects. - Check which module the block is in. A move into or out of a child module has to be written in the parent, because that is the only place both addresses can be named.
- Check you are planning the right workspace.
terraform state listprints every address the current state knows about. If the old address is not on that list, this state has already been moved and the destroy is coming from something else. - Do not apply it to see what happens. The destroy in that plan is the real one. Nothing about an apply is a rehearsal.
What else it can move
A rename is the simplest case. The same block also covers the moves that come up in a real refactor:
One instance of a counted or keyed resource
The instance key is part of the address, so changing it changes the address. This is the count to for_each migration, and it needs one block per instance:
moved {
from = aws_subnet.this[0]
to = aws_subnet.this["public"]
}Into or out of a module
The address gains or loses a module prefix, and the block goes in the calling module, which is the only place both addresses exist:
moved {
from = aws_subnet.app
to = module.network.aws_subnet.app
}A whole module call
Renaming the call moves everything inside it, in one block, without naming a single resource:
moved {
from = module.legacy_network
to = module.network
}What it cannot do is change a resource's type. Terraform can move an object to a different address of the same type and no further. A different type means a different schema, with nothing to carry the old state entry across, and that case is an import or a destroy and a create.
How long to keep the block
A moved block whose from address is not in the state does nothing at all. That is the property everything else here rests on: the same block can be applied any number of times, by any number of people, and only the first one does any work.
So keep it until everyone who runs this configuration has applied it. Every workspace, every state, CI included. Deleting it the moment your own apply succeeded means the next person to plan against an unapplied state sees an address with no configuration, and gets a destroy.
In a module you publish, keep it until the next major version. A consumer can upgrade from any earlier version, so the block has to still be there when they do.
moved, or terraform state mv
Both end with the same state entry under a new address. terraform state mv is state surgery, run once, by one person, against the one state they pointed at, with nothing in the repository to show it happened. A moved block is a code change, and a rename is a code change. Use the block unless you cannot.
The one place you cannot is a move between two separate state files, which no configuration construct can express, so splitting or merging state is still the command's job. The full comparison, the removed block that does the same for terraform state rm, and what each of them leaves behind are on the state mv and state rm page.
What happens if you get it wrong
Four failures, in rough order of how much they cost.
Pointing at the wrong resource
This is the expensive one, and it is the reason to be careful with any tool that suggests a moved block for you. Terraform believes the block. It files the old object's state entry under the new address, and the next plan compares your new configuration against whatever real object that entry pointed at. You have adopted one resource's state under another resource's name. That is worse than the destroy you were trying to avoid, because the destroy would at least have been in the plan.
Removing the block too early
Anyone who has not applied yet still has the old address in their state. Their next plan sees a state entry with no configuration and plans a destroy, which is the exact thing the block was written to prevent, arriving later and to somebody else. In a published module it reaches every consumer still on an older version.
Leaving the old address declared
Terraform rejects a configuration in which the from address is still declared as a resource. An object cannot both have moved and still be there. This one fails loudly at plan time, which makes it the cheapest of the four.
Expecting it to change something real
A moved block moves a state entry and nothing else. It does not touch your infrastructure and it cannot rename anything in your cloud account. If the same change also alters an attribute the provider cannot update in place, you still get a replacement: the block only means the replacement now happens under the new address. Read the plan rather than assuming the block made the whole change free.
Finding the ones somebody forgot
The failure this page is really about is not writing a bad moved block. It is not writing one at all, and approving the plan anyway. A destroy and a create sit hundreds of lines apart in a real plan, neither of them looks like the other, and nothing in Terraform's output says the two might be the same resource.
terrakit is a command-line tool that reads a plan and says so. It finds destroys and creates of the same resource type, and compares their top-level attributes:
terrakit 2 findings terraform 1.16.1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
HIGH ────────────────────────────────────────────────────────────────────── 1
azurerm_subnet.app
destroy
└ possible missed moved block
5 of 5 attributes match azurerm_subnet.application
moved { from = azurerm_subnet.app to = azurerm_subnet.application }
verify the pairing before using that block
INFO ────────────────────────────────────────────────────────────────────── 1
azurerm_subnet.application
create
├ these values are not known until apply, so no claim about them can be
│ checked in review
│ etag
│ id
└ possible missed moved block
paired with azurerm_subnet.app, shown above
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1 high 1 infoIt shows its working because it is a heuristic and not a proof, and it says so every time it fires. The rules it uses are worth knowing if you are going to trust it:
- At least three comparable attributes, and at least 80 per cent of them matching. Two nearly-empty resources will agree on almost everything and mean nothing by it.
- Attributes Terraform marks unknown until apply are dropped before counting, because there is nothing yet to compare. So are attributes that are null on both sides: state is full of unset optional attributes, and counting agreement on absence pairs unrelated resources.
- It pairs across module boundaries as well as within one, because moving a resource into or out of a module is one of the commonest reasons to write a moved block. A same-module match wins only when two candidates are otherwise exactly tied.
- It skips anything Terraform already reports as moved, so a plan that has its blocks is quiet.
- Very small resource types rarely clear the three-attribute bar, so a rename of a
terraform_dataor a barelocal_filegoes unflagged. The tool's own test fixtures contain exactly that case, and the detector correctly stays silent on it.
The suggested block it prints is marked as needing verification, and that is not a formality. Pasting the wrong pairing is the first failure in the list above.