skip to content

A live Terraform configuration manages 30 EC2 instances with count over a list, and you need to switch it to for_each keyed by instance name without destroying anything. How do you carry out that migration?

level: seniorimportance: should knowfreq 42%

answer

  1. addresses changed, objects did not
  2. declare the rename in code
  3. one entry per instance
  4. read the mapping from state
  5. zero to add, change, destroy

basics

~20 s

Rewrite the block to use for_each, then add a moved block for every instance mapping the old index address to the new key address. Terraform re-addresses the existing objects in state and the plan should report zero adds, changes and destroys.

solid answer

~50 s

Changing `count` to `for_each` changes every instance address, so a naive edit plans 30 destroys and 30 creates — the addresses in state no longer match anything the configuration produces. The supported fix is the `moved` block, available since Terraform 1.1: alongside the rewritten resource you declare `moved { from = aws_instance.web[0] to = aws_instance.web["api"] }` for each instance, mapping index to key. On the next plan Terraform applies the renames to state first and then diffs, so the correct outcome is a list of "has moved to" lines and `0 to add, 0 to change, 0 to destroy`. The care needed is in the mapping itself: read the current state to learn which index really holds which instance rather than assuming the list order in the file. Keep the moved blocks in the repository until every workspace and environment has applied them, then delete them.

code

hcl · 16 lines
hcl
resource "aws_instance" "web" {
  for_each      = var.instances
  ami           = each.value.ami
  instance_type = each.value.instance_type
  tags          = { Name = each.key }
}

moved {
  from = aws_instance.web[0]
  to   = aws_instance.web["api"]
}

moved {
  from = aws_instance.web[1]
  to   = aws_instance.web["worker"]
}

go deeper

for a junior

Know that a moved block tells Terraform an address was renamed, and that without one, changing count to for_each looks like deleting everything and building it again.

for a middle

Explain that moves are processed before the diff so state is re-addressed first, and write the from/to pairs mapping each index to its key. Note that moved blocks require Terraform 1.1 or later.

for a senior

Own the risk: derive the mapping from state rather than from the file, gate on a zero-change plan, roll it through environments one state at a time, and remove the blocks only once every workspace has applied.

for a principal

Judge whether the migration is worth doing at all, and decide the policy — which repositories must move off count, who reviews address-changing refactors, and what evidence a pull request must carry before an estate-wide re-address is approved.

## Why the naive edit is destructive State maps resource addresses to real objects. A `count` resource occupies `aws_instance.web[0]` … `[29]`; the same block with `for_each` would occupy `aws_instance.web["api"]`, `["worker"]`, and so on. Terraform does not infer that `[0]` and `["api"]` are the same machine — nothing in the data says so. The plan therefore reads: 30 addresses in state with no configuration (destroy) and 30 addresses in configuration with no state (create). Every instance is rebuilt for a refactor that changed no real infrastructure. ## The moved block A `moved` block is a declaration in the configuration that one address has been renamed to another. It is processed before diffing, so the state entry is re-addressed and the subsequent comparison finds a match: ```hcl resource "aws_instance" "web" { for_each = var.instances # map keyed by name ami = each.value.ami instance_type = each.value.instance_type tags = { Name = each.key } } moved { from = aws_instance.web[0] to = aws_instance.web["api"] } moved { from = aws_instance.web[1] to = aws_instance.web["worker"] } ``` Thirty instances means thirty `moved` blocks. They are mechanical, and generating them from the current state listing is both faster and safer than typing them. `moved` arrived in Terraform 1.1. Before that, the equivalent was a sequence of manual state-surgery commands run by a human against the remote state — which is exactly why the declarative form exists: it is code-reviewed, it runs in CI, and it applies identically in every workspace that uses the configuration. ## Getting the mapping right The migration's only real risk is mapping the wrong index to the wrong key. The index reflects the list *as it was when each instance was created*, and that list may have been edited since. Do not read the mapping out of the current `.tf` file; read it out of the current state, where each indexed address carries the actual attributes (the Name tag, the private IP, the AMI) that tell you which machine it is. A mis-mapping is not caught by Terraform — both addresses are valid — so it surfaces as an unexpected in-place update or, worse, a replacement of a machine that should not have changed. The plan is your check: anything other than `0 to add, 0 to change, 0 to destroy` (plus the move notices) means the mapping is wrong, and you stop and fix the mapping rather than approving the diff. ## Sequencing across environments Each workspace or environment has its own state and must apply the moves itself. Sequence it deliberately: 1. Merge the rewrite plus the `moved` blocks. 2. Plan in the lowest environment; confirm a pure-move plan. 3. Apply, then repeat environment by environment up to production. 4. Only after every state has applied, remove the `moved` blocks in a follow-up change. Removing them too early leaves a state still holding index addresses, and the next plan there rebuilds everything — the very outcome the migration was avoiding. A `moved` block whose `from` address is absent is harmless, which is what makes it safe to keep during the rollout window. ## Is the migration worth it? For 30 named machines, yes. `count` guarantees that the next edit to the middle of the list churns unrelated instances, and the cost of that outage exceeds the cost of one afternoon of mechanical mapping. The exceptions are worth naming: if the instances are genuinely interchangeable and the list only ever grows at the end, `count` is honest and the migration is churn for its own sake. If the objects are cheap and stateless and a rebuild is genuinely acceptable, you can also simply let it destroy and recreate — but that is a decision to make explicitly and announce, not one to discover in a plan at 5pm on a Friday. ## The reviewable outcome The artefact that proves the migration is correct is the plan output: a block of "aws_instance.web[0] has moved to aws_instance.web[\"api\"]" lines and a zero-change summary. Attach it to the pull request. A refactor that touches every address in state should be reviewed on evidence, not on intent.

  • What does the plan look like if you make the switch without any moved blocks?
    Thirty destroys and thirty creates. Every state address is orphaned because the configuration now produces key addresses, and every key address is new because state holds only indices. Terraform is behaving correctly — it has no way to know that `[0]` and `["api"]` describe the same machine unless you tell it.
  • When is it safe to delete the moved blocks?
    Once every state that uses this configuration has run an apply containing them — each workspace and environment separately. Until then a state still holding index addresses would rebuild everything on its next plan. Removing them afterwards is a tidy-up commit; a moved block whose from address no longer exists is simply ignored.
  • How do you verify the mapping was right, given Terraform accepts any valid pair of addresses?
    By the plan, not by inspection. A correct mapping yields move notices plus `0 to add, 0 to change, 0 to destroy`. Any in-place update or replacement means an index was mapped to the wrong key — stop, re-derive the mapping from the attributes recorded in state, and re-plan before approving.

saying these in an interview costs you the question

  • Assumes Terraform matches instances by attributes automatically
  • Reads the index-to-name mapping from the current tf file
  • Deletes the moved blocks before every environment applied
  • Approves a plan showing replacements as normal for a refactor
  • Thinks the move requires downtime or a maintenance window

context