The four steps
- Rename the resource in the configuration, and update every reference to it. Terraform will tell you about the references you missed; it will not tell you about the rename.
- Add a moved block in the module whose addresses it names, with the old address as
fromand the new one asto. - Read the plan. It should report the move and plan nothing. This is the step people skip and it is the one that catches a typo in an address.
- Apply, and leave the block in place until everyone who runs this configuration has applied it, CI included. A moved block whose
fromaddress is not in the state does nothing, so there is no cost to leaving it and a real cost to removing it early.
Why any of this is necessary is the subject of the moved block page: the short version is that a resource's address is the only identity Terraform has for it, so changing the address makes an old object look deleted and a new one look invented.
How to tell whether the destroy has gone
Step three is the one that decides whether the job is done, and it is worth being precise about what you are looking for. Terraform accepting the block means the syntax is valid; it does not mean the destroy has gone. A block with a typo in an address is valid configuration that does nothing at all, and Terraform reports nothing when a from address is not in the state, because that is also what a block somebody else already applied looks like.
Right. The move is reported and nothing is planned:
# azurerm_subnet.app has moved to azurerm_subnet.application
resource "azurerm_subnet" "application" {
...
}
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 expected. What must not be there is a destroy.
Still wrong. The destroy and the create are both there, usually hundreds of lines apart, and the block might as well not exist:
# azurerm_subnet.app will be destroyed
# (because azurerm_subnet.app is not in configuration)
- resource "azurerm_subnet" "app" {
...
}
# azurerm_subnet.application will be created
+ resource "azurerm_subnet" "application" {
...
}
Plan: 1 to add, 0 to change, 1 to destroy.If a destroy is still there, in order:
- Copy both addresses out of the plan rather than retyping them. The plan prints the address Terraform holds in state and the address your configuration declares, and the block has to name those two exactly.
- Check the instance key. With
countorfor_eachthe key is part of the address and you need one block per instance. - Check which module the block is in. A move into or out of a child module belongs in the parent, which is the only place both addresses can be written.
- Check you are planning the right workspace.
terraform state listprints the addresses this state actually holds. If the old one is not on that list, the destroy is coming from something other than your rename. - Do not apply it to find out. The destroy in that plan is the real one.
Renaming inside one module
The simplest case, and the one behind most of these searches. The block goes in the same module as the resource:
resource "azurerm_subnet" "application" {
name = "app"
# ... unchanged
}
moved {
from = azurerm_subnet.app
to = azurerm_subnet.application
}from and to take addresses, unquoted. A moved block accepts nothing else: no count, no for_each, no variables, no expressions. The addresses are literal.
Moving between modules
A module prefix is part of the address, so moving a resource into a module changes its address and Terraform plans a destroy exactly as it would for a rename. The block goes in the module that can see both addresses, which for a move into or out of a child module means the parent that calls it.
Into a module:
moved {
from = azurerm_subnet.app
to = module.network.azurerm_subnet.app
}Out of one, which is the same move written the other way round:
moved {
from = module.network.azurerm_subnet.app
to = azurerm_subnet.app
}Between two modules, where both prefixes change:
moved {
from = module.legacy.azurerm_subnet.app
to = module.network.azurerm_subnet.app
}If what you are actually doing is renaming the module call itself, there is no need to list its resources. One block moves everything inside it:
moved {
from = module.legacy
to = module.network
}Changing a for_each key
This is the one that catches people, because nothing in the diff looks like a rename. The instance key is part of the address, so changing a key in the map you iterate changes the address of that instance, and Terraform plans a destroy and a create for every instance whose key moved.
variable "subnets" {
default = {
app = "10.0.1.0/24"
db = "10.0.2.0/24"
}
}
resource "azurerm_subnet" "this" {
for_each = var.subnets
name = each.key
# ...
}Rename those two map keys to application and database and you have renamed two subnets. Not the name attribute, which has not changed, but the addresses, which is what Terraform is tracking. Both are planned for destruction and two new ones are planned in their place.
The fix is one block per key:
moved {
from = azurerm_subnet.this["app"]
to = azurerm_subnet.this["application"]
}
moved {
from = azurerm_subnet.this["db"]
to = azurerm_subnet.this["database"]
}There is no wildcard and no way to generate these. A moved block takes literal addresses, so it cannot use for_each itself, and a large re-key is a long list written out by hand. That is worth knowing before you start, because the alternative people reach for when the list looks long is to apply it anyway.
Note that a key change is invisible in the way a rename is not. When you rename a resource label, the diff shows the old name on one line and the new one on the next. When you edit a map, the diff shows a string changing inside a variable, and nothing about it says this destroys two subnets.
Migrating count to for_each, and back
Under count the instance key is a number; under for_each it is a string. Both are ordinary addresses, so the migration is a moved block per instance:
moved {
from = azurerm_subnet.this[0]
to = azurerm_subnet.this["application"]
}
moved {
from = azurerm_subnet.this[1]
to = azurerm_subnet.this["database"]
}Get the pairing right. The index a resource had under count came from its position in a list, and the key it gets under for_each comes from the map, and nothing checks that you have matched them up correctly. Terraform will happily file subnet zero under the key database if that is what you wrote, and the next plan will then try to make the real subnet zero match the database configuration.
The same blocks work the other way, from for_each back to count, with the same warning about pairing.
Renaming things that are not resources
- A variable, an output or a local. None of these has a state entry that maps to a real object, so renaming one costs nothing and needs no block. Fix the references and you are done. An output renamed in a published module is a breaking change for consumers, but it is a breaking interface, not a destroy.
- A data source. It is re-read on every plan rather than created and destroyed, so renaming one costs nothing real.
- A module call. One block moves the call and everything under it, as above.
- A provider's source address. Not a rename in this sense at all, and not something a moved block covers.
terraform state replace-provideris the command for that, and it is state surgery rather than a code change.
What you cannot rename this way
- The resource type. Terraform can move an object to a different address of the same type and no further. A different type has a different schema and there is nothing to carry the old entry across, so that case is an import, or a destroy and a create.
- Anything in your cloud account. A moved block moves a state entry. It does not rename a database, a bucket or a subnet, and it never makes an API call to try. If the real name is an attribute the provider cannot update in place, changing it still forces a replacement, and the moved block only means the replacement happens under the new address. Read the plan rather than assuming the block made the whole change free.
Catching a rename in somebody else's plan
Writing the block is the easy half. The hard half is the plan that arrives in a pull request with no block in it, where a destroy and a create sit hundreds of lines apart and nothing says they might be the same resource. An agent refactoring a module is very good at producing exactly that.
Terraken reads the plan JSON and pairs them up:
terraken 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 compares the top-level attributes of every delete against every create of the same resource type, needs at least three comparable attributes and an 80 per cent match, and reports the count it matched over the count it compared so you can judge the pairing yourself. It pairs across module boundaries too, because moving a resource into or out of a module is one of the commonest reasons the block was needed in the first place.
The block it prints is marked as needing verification, and that matters more than it sounds: pasting a wrong pairing adopts one resource's state under another resource's name, which is worse than the destroy it was meant to prevent. The rules and the edges are on the moved block page.