terraken

Terraken

Rename a Terraform resource without destroying it

Rename it in the configuration, then add a moved block naming the old address and the new one. Terraform updates the state entry instead of planning a destroy and a create, and nothing in your cloud account is touched. The same block covers moving a resource into a module, out of one, between two, and changing a for_each key.

The four steps

  1. 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.
  2. Add a moved block in the module whose addresses it names, with the old address as from and the new one as to.
  3. 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.
  4. Apply, and leave the block in place until everyone who runs this configuration has applied it, CI included. A moved block whose from address 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:

terraform plan, abridged
  # 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:

terraform plan, abridged
  # 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 count or for_each the 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 list prints 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:

Terraform
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:

Terraform
moved {
  from = azurerm_subnet.app
  to   = module.network.azurerm_subnet.app
}

Out of one, which is the same move written the other way round:

Terraform
moved {
  from = module.network.azurerm_subnet.app
  to   = azurerm_subnet.app
}

Between two modules, where both prefixes change:

Terraform
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:

Terraform
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.

Terraform
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:

Terraform
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:

Terraform
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-provider is 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 plan.json
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 info

It 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.

Questions about renaming

How do I rename a Terraform resource without destroying it?
Rename it in the configuration, then add a moved block naming the old address as from and the new one as to. Run terraform plan and confirm it reports the move and no destroy. Apply, then leave the block in place until everyone who runs this configuration has applied it.
How do I move a resource into a module?
The new address gains a module prefix, so aws_subnet.app becomes module.network.aws_subnet.app. Write the moved block in the parent module that calls the child, because that is the only place both addresses exist. Moving a resource out of a module is the same move written the other way round, and renaming the module call itself moves everything inside it in one block.
What happens when a for_each key changes?
The key is part of the address, so changing it changes the address and Terraform plans a destroy and a create for every instance whose key moved. A moved block per key fixes it: from aws_subnet.this with the old key, to aws_subnet.this with the new one. There is no wildcard and no way to generate the blocks, so a large re-key is a long list written out by hand.
Can I move from count to for_each without destroying anything?
Yes, with one moved block per instance. Under count the instance key is a number, and under for_each it is a string, so aws_subnet.this[0] becomes aws_subnet.this with the name as its key. Both are ordinary addresses and a moved block moves between them. Doing it the other way, from for_each to count, works the same way.

Terraken uses Google Analytics to count how many people read these pages. No cookie is set unless you accept, and every page works exactly the same either way.