Skip to content
SRE & DevOpsDeep Dive Published Updated 10 min readViews unavailable

Terraform Moved Blocks: Safe Resource Renames and Module Refactors

Preserve Terraform-managed objects through address refactors with moved blocks, reviewed plans, instance mappings, and durable module upgrade history.

Terraform state binds a configured resource address to a real remote object. If a resource block is renamed or moved into a module, Terraform sees a different address. Without an explicit migration, a plan can propose destroying the object at the old address and creating another at the new one. A moved block records that the address changed while preserving the state binding, so a configuration refactor does not have to become an infrastructure replacement.

Moved blocks are declarative migration history. They belong in configuration, are visible in code review, and let users of a module apply a planned state transition through the ordinary plan/apply workflow. They do not make an unsafe resource change safe, guarantee that provider arguments will be unchanged, or replace careful plan review. This guide covers their limits and a production workflow for resource, instance, and module refactors.

What a move changes - and what it does not

Terraform identifies managed objects by their resource address, such as aws_instance.web or module.edge.aws_lb.frontend. A moved block says that an object previously recorded at one address should now be treated as being at another address:

moved {
  from = aws_instance.web
  to   = aws_instance.api
}

The from and to values are Terraform addresses, not strings. Before planning the destination resource, Terraform checks state for an object at the old address and plans a state-address change if it finds one. The remote object is not deleted merely because its address changed.

The destination block still needs a valid configuration. After accounting for the move, Terraform refreshes and compares the object’s attributes with the destination configuration. An address move can therefore be accompanied by an in-place update or a replacement if other arguments changed or the provider reports that a change forces replacement. Review the full plan; the word “moved” is not a blanket promise that the apply contains no remote API calls.

Moved blocks were introduced in Terraform v1.1. A consumer using an older CLI cannot use this declarative mechanism; module authors should state and test their supported Terraform version instead of assuming every caller uses the latest binary. For versions before v1.1, HashiCorp documents terraform state mv as the explicit alternative for an address refactor, with careful coordination around the shared state.

Rename a resource without replacing it

Suppose a repository initially declared:

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = var.instance_type
}

The team decides that api is a clearer name. In the same change, rename the resource block and add the migration:

resource "aws_instance" "api" {
  ami           = var.ami_id
  instance_type = var.instance_type
}

moved {
  from = aws_instance.web
  to   = aws_instance.api
}

Run a plan using the same workspace and state that will receive the change. For a pure rename with unchanged arguments, the plan should report the old address as moved to the new one and should not propose replacing the instance. If it also shows a configuration update, investigate that separately; if it proposes destroy/create, stop and determine whether the address, state, provider configuration, or resource type differs from the intended move.

A moved block only acts on state addresses visible to the module in which it is declared. A reusable module can move its own resources and resources in its child modules, but cannot arbitrarily rewrite objects owned by an unrelated parent configuration. Keep the from address relative to the module where the block lives and validate the resulting full address in the plan.

Map instances when changing count and for_each

Instance keys are part of the address. When changing a singleton or indexed resource to a keyed collection, write explicit mappings for the existing objects that should survive. For example, if the prior configuration used count and the new one uses for_each:

resource "aws_instance" "worker" {
  for_each      = var.workers
  ami           = var.ami_id
  instance_type = each.value.instance_type
}

moved {
  from = aws_instance.worker[0]
  to   = aws_instance.worker["primary"]
}

The map key should correspond to the intended new instance, not to a guessed ordering. If multiple old indexes exist, define and review each mapping. If some old instances are intentionally being retired, leave them unmapped only when the resulting destruction is expected and separately approved. A move that maps an old instance to the wrong key can preserve the wrong remote object under a misleading logical name.

When removing count or for_each, the source and destination can likewise map a particular instance to an unkeyed resource. Test the exact addresses from the real state. Avoid inventing migrations by looking only at current HCL: the state may contain indexes or keys created by prior releases that are no longer obvious in the configuration.

Move a resource into a child module

Moving declarations into a module changes every full resource address. Keep the module interface and resource behavior stable while recording each address transition:

module "network" {
  source = "./modules/network"
  cidr   = var.network_cidr
}

moved {
  from = aws_vpc.main
  to   = module.network.aws_vpc.main
}

The move block is evaluated in the root module because that is where the old root-level address was owned. If the resource was already inside another module, use its prior module path in from. Changes to module inputs, provider aliases, resource type, and resource arguments are independent dimensions; combine them with a move only when the plan remains understandable and testable. Otherwise split the refactor into smaller changes so reviewers can distinguish address migration from behavior changes.

Terraform also supports moves of module calls and objects within a module’s own child modules. For example, renaming a call from module.old_network to module.network can be represented by moving the module address itself. The plan must show the expected descendants under the new path. Don’t add a move from an address the module does not own, and don’t infer that a moved block is a cross-workspace or cross-backend state migration.

Preserve the migration chain for module consumers

If a public module renames an internal resource, users may upgrade from several older releases. Keep the historical moved blocks so a user who skips intermediate versions can still map a state address from the release they actually ran to the current address. If the object moved twice, retain a chain:

moved {
  from = aws_instance.legacy
  to   = aws_instance.api
}

moved {
  from = aws_instance.api
  to   = module.service.aws_instance.api
}

Removing an old move block can be a breaking change. A consumer with state at the old address may then receive a plan to destroy the old object and create a new one. Module maintainers should treat these blocks as compatibility metadata, document when a block is intentionally removed, and only remove one when they know no supported user state depends on that address. In a widely distributed module, that assurance is often difficult to establish; retaining the migration history is usually safer.

Do not write two competing paths that map the same source to different destinations. Keep moves acyclic and test paths from each supported historical address to the current one. A small migration table in release notes can help reviewers identify which older versions are covered.

Review and apply the migration safely

Use a controlled change process:

  1. Record the exact Terraform CLI version, workspace, backend, and state lineage. Confirm that the migration is within one state and configuration boundary.
  2. Back up state according to the backend’s supported procedure, and verify that the normal locking mechanism is active. Avoid hand-editing state JSON.
  3. Make the address change and its moved block in one reviewed change. Keep unrelated provider, argument, and module-interface changes out when possible.
  4. Run formatting and validation, then create a saved plan against the intended workspace. Check every source and destination address in the output.
  5. Confirm that mapped objects are not unexpectedly replaced, that intentionally retired instances are accounted for, and that no unrelated resources change.
  6. Apply the reviewed plan through the normal pipeline, then run a fresh plan and state inspection to verify convergence.

For a remote backend, only one authoritative workflow should operate on the state during the migration. State locking prevents concurrent state writes when supported and configured, but it does not make two independently planned configuration changes semantically safe. Coordinate merges and applies so that another run does not plan from a different configuration while the address change is pending.

If a move is only needed to repair a one-off local configuration or to transfer an object between separate state files, a moved block may not be the appropriate operation. The CLI command terraform state mv updates bindings in state and requires precise coordination, especially with remote state and collaborators. Use it only when the declarative same-configuration migration is not applicable, capture a backup, and follow the backend-specific procedure. Do not run both a manual state move and a moved-block migration for the same source without checking the resulting plan.

Common failure patterns

Plan or runtime symptom Likely cause Safer next check
Plan proposes destroy and create after a rename Missing block, wrong from, wrong module scope, or state does not contain the expected source Inspect the exact state address and plan’s address mapping before applying
Plan still proposes replacement after the move Destination arguments changed or the provider marks a difference as replacement-required Separate address movement from configuration changes and inspect provider-specific replacement fields
One instance moves but others are destroyed Only one count index/key mapping was declared Enumerate prior state instances and map only the intended survivors explicitly
A module upgrade fails for users skipping releases Historical move was removed or an intermediate address is not covered Restore a compatible migration chain and test representative old states
Plan reports conflicting or invalid moves Duplicate source/destination paths or addresses outside the block’s module scope Simplify the graph and verify address ownership from configuration structure
A coworker’s plan disagrees with the reviewed plan Different CLI version, workspace, state snapshot, configuration revision, or concurrent operation Recreate the plan from the exact authoritative inputs; do not apply a stale plan blindly

Provider-specific state migrations and import behavior are separate from address refactoring. If a refactor also changes resource type or provider implementation, confirm that the provider supports the state conversion and use its documented migration path. A successful address mapping by itself does not certify that the new resource schema can interpret the existing state.

Production checklist

  • Every intended prior address is recorded exactly, including module path and instance key.
  • Every destination exists in the new configuration and represents the same managed object.
  • The generated plan shows the expected move and no accidental replacements or unrelated changes.
  • The move stays within the correct Terraform configuration and state boundary.
  • Module releases retain migration history for supported users who skip versions.
  • CLI version, backend locking, plan freshness, state backup, and apply ownership are verified.
  • The post-apply plan is converged and the state now records only the intended current addresses.

Use moved blocks to make address changes explicit, reviewable, and compatible with normal Terraform workflows. The code block is only one part of the migration: the real proof is a plan against the correct state that shows the exact object moving to the intended address without an unreviewed infrastructure change. Preserve that proof in code review and retain the move history for as long as supported states may depend on it.

Related:

Sources:

Comments