terrakit

terrakit

Terraform moved blocks

A moved block tells Terraform that an object in your state used to live at one address and now lives at another. Without one, renaming a resource destroys it and builds a new one, because the address is the only identity Terraform has. With one, the state entry is updated and nothing in your cloud account is touched.

The whole construct, for a resource whose label changed from db to database:

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

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

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

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

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

terraform plan, abridged
  # 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 count or for_each, the key is part of the address and the block needs one entry per instance. this[0] and this["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 list prints 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:

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

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

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

It 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_data or a bare local_file goes 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.

Questions about moved blocks

What is a moved block in Terraform?
A moved block records that an object in your state used to live at one address and now lives at another. You write it in your configuration with a from address and a to address, Terraform reads it during the next plan, and it updates the state entry instead of planning a destroy and a create. It was added in Terraform 1.1 and OpenTofu carries it too.
Why does Terraform destroy and recreate a resource when I rename it?
Because the address is the identity. Terraform's state maps an address such as aws_db_instance.db to one real remote object, and nothing else in the entry ties it to your configuration. Rename it to aws_db_instance.database and Terraform sees a state entry with no configuration, which it plans to destroy, and a configuration with no state, which it plans to create. It is not looking at the name and deciding: it genuinely cannot tell the rename apart from a deletion and an unrelated addition.
Where does a moved block go?
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 that is the only place where both addresses can be written. A moved block takes literal addresses, so it cannot be generated with for_each and cannot take a variable.
Do I have to keep the moved block after applying?
Keep it until everyone who runs the configuration has applied it, including CI and every other workspace. A moved block whose from address is not in the state does nothing at all, so leaving it in place costs nothing and removing it too early costs a destroy for whoever had not applied yet. In a module you publish, keep it until the next major version, because a consumer can upgrade from any earlier one.
When should I use a moved block instead of terraform state mv?
Almost always. A moved block is code: it is reviewed in the pull request, it is versioned with the change it belongs to, and it runs for everyone and every workspace on the next plan. terraform state mv is one command, run once, by one person, against one state, with nothing in the repository to show it happened, and it needs write access to that state. Reach for state mv only where the change cannot be expressed in configuration at all, such as moving an object between two separate state files.
Can a moved block change a resource's type?
No. Terraform can move an object to a different address of the same type, not to a different type. Changing the type means a genuinely different resource with a different schema, and there is nothing to carry the old state entry across. That case is an import, or a destroy and a create.
What happens if a moved block points at the wrong resource?
Terraform believes you. It files the old object's state entry under the new address, and the next plan compares the new configuration against whatever real object the old entry pointed at. You have adopted one resource's state under another resource's name, which is worse than the destroy you were trying to avoid, and it is why any tool that suggests a moved block should tell you to verify the pairing before you use it.

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