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 commands | Blocks in configuration | |
|---|---|---|
| When state changes | Immediately, on the operator's machine | During terraform apply, like any other change |
| Reviewable | Only if someone pastes the commands into a ticket | In the pull request, next to the code change |
| Visible in plan | No | Yes, the plan shows each move and import |
| Repeatable across environments | Run by hand per workspace | Applied 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:
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
countorfor_each: moveaws_iam_role.deploytoaws_iam_role.deploy["payments"]. - Extracting a module: move
aws_db_instance.orderstomodule.orders_db.aws_db_instance.this. - Renaming a module call: move
module.dbtomodule.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:
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:
terraform plan -generate-config-out=generated.tfTreat 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 {
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:
- Add the resources to the new configuration with
importblocks, and plan until it shows only imports. - Add
removedblocks withdestroy = falseto the old configuration for the same resources, and delete their resource blocks there. - Apply the old configuration first, so the resources are released.
- Apply the new configuration, which imports them.
- 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
-outand inspect it withterraform show -json. A shortjqfilter can fail CI if a refactor branch contains anydeleteaction. - Run the plan against every environment that uses the code before merging, not just the first one.
Rollout steps
- Upgrade to Terraform 1.7 or later so
moved,importwithfor_each, andremovedare all available. - Replace any runbooks that use
terraform state mv,importorrmwith the block equivalents. - Do one refactor per pull request, and require a plan with no destroys before merge.
- Apply through the normal pipeline, environment by environment.
- Delete
movedandimportblocks from root configurations once every environment is applied. Keepmovedblocks in shared modules until consumers have upgraded.
For how to lay out state boundaries in the first place, see structuring Terraform for many environments.