Skip to content

InsightsInfrastructure as Code

Refactoring Terraform state with moved and import blocks

How to rename resources, extract modules, adopt existing infrastructure and split state files using moved, import and removed blocks, with plans you can review.

Published July 30, 20245 min read

Terraform code needs refactoring like any other code. Resources get better names, repeated blocks become modules, a single state that covers everything gets split by team. The difficulty is that Terraform tracks resources by address. Rename aws_s3_bucket.bucket to aws_s3_bucket.exports, and the plan says it will destroy one bucket and create another.

For years, the answer was terraform state mv and terraform import run by hand against production state. Those commands work, but they change state immediately, outside any plan and outside code review. Since Terraform 1.1 and 1.5, the same operations can be written as code: moved and import blocks. Since 1.7, removed completes the set.

Why config-driven refactoring is better

CLI commandsBlocks in configuration
When state changesImmediately, on the operator's machineDuring terraform apply, like any other change
ReviewableOnly if someone pastes the commands into a ticketIn the pull request, next to the code change
Visible in planNoYes, the plan shows each move and import
Repeatable across environmentsRun by hand per workspaceApplied by the same pipeline everywhere

The last row matters most when the same code backs several environments. One pull request refactors staging and production in the same way, through the normal pipeline.

Renaming with moved

A moved block tells Terraform that an object at one address now lives at another:

storage.tf
resource "aws_s3_bucket" "exports" {
  bucket = "orders-exports-prod"
}
 
moved {
  from = aws_s3_bucket.bucket
  to   = aws_s3_bucket.exports
}

The plan reports that aws_s3_bucket.bucket has moved to aws_s3_bucket.exports and proposes no other changes. That is the signal to look for. If the plan also shows a replacement, the refactor changed something else too.

The same block handles the common refactors:

  • Adding count or for_each: move aws_iam_role.deploy to aws_iam_role.deploy["payments"].
  • Extracting a module: move aws_db_instance.orders to module.orders_db.aws_db_instance.this.
  • Renaming a module call: move module.db to module.orders_db, which moves everything inside it.

Where to keep moved blocks

A moved block is only needed until every state that uses the code has been applied. For a root configuration with a handful of environments, remove the blocks once all of them are applied. For a shared module consumed by other teams, keep them much longer, because each consumer upgrades on their own schedule. Removing a moved block from a published module before every consumer has applied it is a breaking change.

Adopting existing resources with import

Import blocks, added in Terraform 1.5, bring resources that were created by hand or by another tool under management:

imports.tf
import {
  to = aws_s3_bucket.exports
  id = "orders-exports-prod"
}

The id is whatever the provider's import documentation specifies for that resource type, usually listed at the bottom of its page in the registry.

Writing the matching resource block by hand is the tedious part. Terraform can draft it:

generate configuration for imports
terraform plan -generate-config-out=generated.tf

Treat the generated file as a starting point. It contains every attribute the provider returned, including defaults and computed values you do not want to pin. Trim it, replace literal values with references, and move the resources into the right files. Then run a normal plan. The target is a plan that shows the imports and zero changes. Any proposed change means the configuration does not yet describe the real resource.

Since Terraform 1.7, import blocks accept for_each, which helps when adopting many similar resources at once.

Removing without destroying

The opposite of import is telling Terraform to forget a resource while leaving it in place. Before 1.7 this was terraform state rm. Now it can be reviewed like everything else:

removed.tf
removed {
  from = aws_s3_bucket.legacy_reports
 
  lifecycle {
    destroy = false
  }
}

The plan shows that the resource will no longer be managed and that it will not be destroyed. Delete the resource block itself in the same change.

Splitting a state

moved blocks work within a single state. To move resources from one state to another, combine removed and import:

  1. Add the resources to the new configuration with import blocks, and plan until it shows only imports.
  2. Add removed blocks with destroy = false to the old configuration for the same resources, and delete their resource blocks there.
  3. Apply the old configuration first, so the resources are released.
  4. Apply the new configuration, which imports them.
  5. Update references. Anything in the old configuration that referred to the moved resources now needs a data source or a remote state output.

Applying in this order means the resources are never managed by two states at once. Between steps 3 and 4 they are managed by none, which is safe as long as nobody applies changes to them in that window.

Reviewing refactoring plans

A refactoring change should have a distinctive plan: moves, imports and removals, and nothing else. Make that easy to check:

  • Keep refactors separate from functional changes. A pull request that renames twenty resources and also changes an instance size buries the one real change.
  • Read the summary line. For a pure refactor it should report zero to add, zero to change and zero to destroy, apart from imports.
  • Save the plan with -out and inspect it with terraform show -json. A short jq filter can fail CI if a refactor branch contains any delete action.
  • Run the plan against every environment that uses the code before merging, not just the first one.

Rollout steps

  1. Upgrade to Terraform 1.7 or later so moved, import with for_each, and removed are all available.
  2. Replace any runbooks that use terraform state mv, import or rm with the block equivalents.
  3. Do one refactor per pull request, and require a plan with no destroys before merge.
  4. Apply through the normal pipeline, environment by environment.
  5. Delete moved and import blocks from root configurations once every environment is applied. Keep moved blocks in shared modules until consumers have upgraded.

For how to lay out state boundaries in the first place, see structuring Terraform for many environments.