Skip to content

Terraform moved blocks: prove the instance mapping

Map each count index to its actual existing object before adopting for_each. A successful move still needs an unchanged-attributes check.

Dopstack Engineering

· 9 min read

A central identity disc connects index cards to named cards around a circular mapping; remote identity sits at the center.

Quick answer

Terraform `moved` blocks record changes to resource addresses. When converting `count` instances to `for_each`, map each old index to the key that represents the same existing object, preserve its configuration, and review the resulting plan. A valid address move does not guarantee unchanged infrastructure if you also change the resource’s arguments. [1][2]

Terraform moved blocks record changes to resource addresses. When converting count instances to for_each, map each old index to the key that represents the same existing object, preserve its configuration, and review the resulting plan. A valid address move does not guarantee unchanged infrastructure if you also change the resource’s arguments. [1][2]

Scenario: a module manages two objects with count. The first belongs to the billing service and the second to the catalog service. The team wants readable keys and easier removal of individual services. The risky shortcut is to infer the old mapping from a newly sorted list. The correct mapping comes from the configuration and state that created the existing objects.

This article develops a refactor decision record and a local terraform_data rehearsal. It does not perform an AWS migration or claim that a cloud provider preserves every attribute during a rename. The goal is to make identity preservation reviewable before an apply can change a real resource.

Which identity is changing?#

A remote object, a Terraform address and a display name are separate things. A resource address is how Terraform associates an instance in configuration with its state. A cloud-side name may be an ordinary argument or a replacement-triggering attribute, depending on the provider and resource type. Renaming the HCL block changes the address; changing an argument can change the managed object.

For example, a worker at count index zero and aws_instance.worker["billing"] are different instance addresses even if both configurations describe the same virtual machine. The move declaration tells Terraform how to interpret that transition. It is not an instruction to ignore the provider’s attribute diff. [1][2]

Write down what the refactor must preserve: the same existing objects remain managed; each old instance maps to one new key; configuration values remain unchanged; and no create, update or delete is part of this refactor. If an intentional infrastructure change is required, separate it into a subsequent plan. That makes an unexpected change easier to locate.

A zero-change plan is useful evidence, but the reviewer still needs to inspect which object became which key. Two interchangeable fixture objects can hide a swapped mapping. Use distinct values in the rehearsal so the plan and state expose a mistaken association.

How do you recover the index-to-key mapping?#

Use the old state and the exact input values for each workspace. A list in the current branch is not historical evidence. Read the old address, the object’s distinguishing attributes and its intended stable service key. Record that relation before modifying the repetition argument.

Existing addressExisting service markerNew address
service at count index zerobillingterraform_data.service["billing"]
service at count index onecatalogterraform_data.service["catalog"]
Reviewed count-index to stable-key mapping

Review each row as an explicit old-to-new identity assignment. In production, add the remote object identifier and workspace name to a restricted change record. Do not publish raw state to a ticket or build log: terraform show -json can expose sensitive values. Saved plans also deserve restricted access. [4][6]

If a workspace used a different list order, its mapping differs. Stop and reconcile that difference instead of using the table as a universal migration. You may need an intermediate refactor that first normalizes configuration without changing object identity, or a workspace-specific migration procedure. A state lock prevents simultaneous writes; it does not correct a semantically wrong mapping.

Choose keys from durable configuration identities. Do not use a list position disguised as a string, a timestamp or an identifier that exists only after a resource is created. Terraform must know the for_each keys while planning, and sensitive values cannot serve as instance identifiers. [3]

Rehearse the address change without a cloud account#

Create a new disposable directory with local state. Do not point this exercise at a production backend. The built-in terraform_data resource keeps the test focused on Terraform addresses; it does not exercise any cloud provider’s replacement rules. The local fixture was exercised with Terraform 1.7.5; explicit moved refactoring is available from 1.1. [1]

Start with the old configuration and apply only in that disposable directory:

hcl
# main.tf, first version
terraform {
  required_version = ">= 1.4, < 2.0"
}

locals {
  services = [
    { key = "billing", label = "svc-billing" },
    { key = "catalog", label = "svc-catalog" },
  ]
}

resource "terraform_data" "service" {
  count = length(local.services)
  input = local.services[count.index]
}

Record the two generated local resource IDs and the input values. Then replace the locals and resource blocks with the following version. Preserve the original terraform block.

hcl
# main.tf, replacement locals and resource blocks
locals {
  services = {
    billing = { key = "billing", label = "svc-billing" }
    catalog = { key = "catalog", label = "svc-catalog" }
  }
}

resource "terraform_data" "service" {
  for_each = local.services
  input = each.value
}

moved {
  from = terraform_data.service[0]
  to   = terraform_data.service["billing"]
}

moved {
  from = terraform_data.service[1]
  to   = terraform_data.service["catalog"]
}

The expected result is a plan that reports the two moves without managed-resource changes. Treat that as an expectation to verify in the local exercise, not a transcript from a provider deployment. After applying the reviewed local plan, compare each new address’s ID and input with the recorded old address.

Now deliberately reverse the two destinations in a disposable copy. Because the inputs differ, the plan should expose mismatched data instead of passing the no-change condition. This negative case matters: a test with identical inputs could preserve two IDs while attaching the wrong business meaning to both.

What should the automated plan check reject?#

Inspect the human-readable plan first, then use plan JSON for a narrow automated gate. The JSON representation records previous_address when an address changes. Use that field to compare the exact expected mapping; use change.actions to detect changes beyond the move. [5]

For a deliberately pure refactor, reject every managed resource action other than no-op. That includes an in-place update, even if the plan has no deletes. A deletion-only guard is appropriate for some policies, but it does not establish the stronger promise made by this refactor.

python
# check_refactor.py: consumes terraform show -json output
import json
import sys

expected = {
    'terraform_data.service[0]':
        'terraform_data.service["billing"]',
    'terraform_data.service[1]':
        'terraform_data.service["catalog"]',
}
plan = json.load(sys.stdin)
changes = plan.get('resource_changes')
if not isinstance(changes, list):
    raise SystemExit('missing resource_changes: review plan format')
seen = {}
for item in changes:
    if item.get('mode') != 'managed':
        continue
    actions = item.get('change', {}).get('actions')
    if actions != ['no-op']:
        raise SystemExit('not a pure refactor: ' + item['address'])
    old = item.get('previous_address')
    if old is not None:
        if old in seen:
            raise SystemExit('duplicate old address')
        seen[old] = item['address']
if seen != expected or len(set(seen.values())) != len(seen):
    raise SystemExit('move mapping differs from reviewed contract')
print('expected mapping; managed actions are no-op')

This fixture checker intentionally expects the first migration plan. Once a workspace has applied the moves, another plan may contain no previous_address entries because its state already uses the new addresses. Do not turn that expected state into a failed deployment indefinitely. Record whether a workspace is pre-migration or already migrated, then compare the appropriate state snapshot.

The script is also not a complete Terraform plan authorization system. It does not review output changes, deferred operations, external hooks or all future JSON schema changes. Keep the Terraform version and plan format under review. A missing field is a reason to inspect the artifact, not to silently interpret it as an empty successful refactor.

Why can a moved block still leave a replacement in the plan?#

Because preserving the address association does not preserve changed arguments. Imagine replacing an old provider-side name with "service-${each.key}" during the same edit. If the former name was different, the provider can propose an update or replacement according to its schema. The move may be correct while the attribute change is unwanted.

Return to the billing object. Its new key can be billing while its remote display name remains the historical value. A stable key does not need to equal every display attribute. Preserve the remote value for the address refactor, then decide separately whether changing the name is worth the provider-specific consequences.

Other failure branches have different repairs. An occupied destination address requires state reconciliation, not another mapping guess. A resource absent from state may be a new instance, a different workspace or an earlier failed procedure. A type conversion requires documented provider support; an address declaration alone cannot make incompatible state schemas compatible. [1]

Do not use -target or suppress refresh to make an uncomfortable plan smaller. A narrow-looking plan can omit the context that reveals the error. The reviewed object is the complete plan for the intended workspace and input set. Any unexpected change invalidates the pure-refactor assumption until someone explains it.

Keep the reviewed mapping attached to the exact plan artifact and input set. If someone regenerates the plan after changing variables, a green check on the earlier JSON says nothing about the new artifact. The release process should identify which plan was inspected and which plan is being applied, using its existing protected artifact mechanism. Store the mapping summary separately so reviewers do not need unrestricted access to every value in the plan. [4][6]

Include an object-level countercheck after the local apply: the billing key must still identify the object whose recorded input says billing, and the catalog key must still identify catalog. Preserved IDs alone are insufficient if the original association was mislabelled in the change record. This check connects the state evidence to the business meaning that motivated readable keys.

For a real provider resource, extend the countercheck with the attributes the application depends on: its endpoint, attached identity or storage reference, as appropriate. Choose those checks before the refactor, while the old association is still available. Do not discover the application contract only after the plan has moved the addresses.

When can the migration declarations be removed?#

Retaining declarations preserves upgrade paths for states that still use old addresses. HashiCorp explicitly treats removing a moved block as a breaking change. That does not mean an already migrated state inevitably destroys its objects when the old declaration disappears. It means a consumer that skipped the migration can lose the information needed to interpret the new configuration. [1]

For a private root configuration, maintain a ledger of all relevant workspaces and their last applied revision. Remove old mappings only after deciding which historic states remain supported. For a reusable module, remember that consumers can upgrade across several versions. Keeping move chains is often cheaper than requiring every consumer to perform a precisely timed intermediate upgrade.

Plan the state correction before applying the moves. Before apply, abandoning the branch normally leaves the old state association intact. After apply, simply checking out the old configuration can propose unexpected changes because the state now uses the new addresses. Review a reverse migration or a forward correction using a fresh plan. Do not assume a Git revert also reverts state.

The release record should name the old and new addresses, preserved identifiers, expected actions, workspaces reviewed, Terraform/provider versions, plan identity and recovery owner. For this scenario, success is a correct mapping and unchanged objects—not merely HCL that validates or a plan whose deletion count happens to be zero.

A state-lock incident needs its own ownership and recovery investigation. Protect the rollout from stale deployment jobs as well as from a mistaken address mapping.

Does moving blocks between files require moved declarations?
Changing only file organization inside the same module normally leaves resource addresses unchanged. A move declaration becomes relevant when the resource or module address changes. Inspect the actual plan rather than assuming every code relocation changes state identity. [1][2]
Can I use one generic block for every count-to-key mapping?
An index-to-key conversion needs the intended association for each existing instance. Write explicit static mappings where instance keys change. A whole-resource rename that preserves repetition has different semantics and can sometimes use a whole-resource move. [1]
Does a zero-delete plan prove a pure refactor?
No. It may still contain creates or in-place updates. For the pure address change in this article, require the expected mapping and no-op managed actions, then compare retained identifiers and distinguishing inputs after the local rehearsal.
Is terraform show JSON safe to attach publicly?
No. Its state and plan representations can include sensitive values. Store the full artifact with restricted access and publish only the reviewed mapping or suitably redacted evidence needed by the change reviewer. [4][6]

Technologies

  • Terraform
  • Infrastructure as code

Sources

  1. [1]
    Refactor modules, HashiCorp

    Read 27 October 2026. Removing historical moved declarations can break an upgrade path.

  2. [2]
    moved block reference, HashiCorp

    Read 27 October 2026. A moved declaration records an address mapping.

  3. [3]
    for_each reference, HashiCorp

    Read 27 October 2026. Sensitive values cannot identify for_each instances.

  4. [4]
    terraform show, HashiCorp

    Read 27 October 2026. JSON state inspection can expose sensitive values.

  5. [5]
    JSON output format, HashiCorp

    Read 27 October 2026. Plan JSON identifies previous addresses when a move changes an address.

  6. [6]
    terraform plan, HashiCorp

    Read 27 October 2026. Saved plans require restricted handling.

Written by

Dopstack Engineering

Engineering team

The engineering team at Dopstack. Illustrative scenarios are composites written to explain a pattern, not accounts of client work.

All articles Website

Get new articles by email

Engineering notes on AI, cloud and SaaS. Occasional, never sold or shared.

Send me new Dopstack Insights articles by email. I can unsubscribe at any time. We’ll email you once to confirm. Privacy

Continue reading