Refactoring Without Replacement: moved, removed and Deprecations
Refactoring Without Replacement: moved, removed and Deprecations
OpenTofu matches configuration to state by address. Rename aws_instance.web to aws_instance.frontend and, as far as the plan is concerned, one object vanished from the configuration and a new one appeared. The plan therefore says destroy one, create one. For a stateless VM that is an outage. For a database or a bucket it is data loss with a green checkmark.
This chapter covers the three declarative tools that let a configuration change shape without touching what it manages. moved changes an address. removed stops managing an object without destroying it. deprecated warns module callers before a breaking change lands.
No tofu binary was available on the authoring box and installs were not allowed, so no plans were executed. Every block below follows the current OpenTofu documentation for the 1.13 series. Plan wording is described, not captured. Before using a pattern in production, run tofu plan and confirm the plan contains no destroys.
Learning goals
- Write
movedblocks for renames,count/for_eachchanges, module renames and module splits - Know when a whole-resource move applies to every instance and when an instance key makes it instance-specific
- Retire a resource from management with
removedanddestroy = false, and run a cleanup provisioner withdestroy = true - Plan the lifetime of these blocks in shared modules, and warn callers with
deprecated
moved: change the address, keep the object
resource "aws_instance" "b" {
count = 2
# ...
}
moved {
from = aws_instance.a
to = aws_instance.b
}
Before planning aws_instance.b, OpenTofu looks for an object at aws_instance.a in state. If it finds one, it re-addresses the object and plans as if it had always been b. Both addresses here name the whole resource, so a[0] becomes b[0] and a[1] becomes b[1] without listing them. A configuration that never had a (a fresh environment using the same module) simply ignores the block.
Changing how many: instance keys
Once either address carries an instance key, the move is about specific instances:
# single resource -> for_each: keep the old object as "small"
moved {
from = aws_instance.a
to = aws_instance.a["small"]
}
# for_each key renamed
moved {
from = aws_instance.b["small"]
to = aws_instance.b["tiny"]
}
# count -> for_each, one block per index
moved {
from = aws_instance.c[0]
to = aws_instance.c["small"]
}
moved {
from = aws_instance.c[1]
to = aws_instance.c["tiny"]
}
# count -> single, keeping index 2
moved {
from = aws_instance.d[2]
to = aws_instance.d
}
Adding count to a resource that had none makes OpenTofu move the existing object to [0] automatically, unless a moved block mentions that resource. Write the block anyway. The next reader sees intent instead of an implicit rule.
The common trap is switching count to for_each over the same list. Without moved blocks, every instance is destroyed and recreated, because [0] and ["web"] are different addresses.
Modules
Renaming a module call moves everything under it:
module "b" {
source = "../modules/example"
}
moved {
from = module.a
to = module.b
}
module.a.aws_instance.example becomes module.b.aws_instance.example, for every instance if the call used count or for_each. Adding count to a module call works like adding it to a resource. Pick the key that inherits the existing objects:
module "a" {
source = "../modules/example"
count = 3
}
moved {
from = module.a
to = module.a[2] # existing objects become instance 2; 0 and 1 are created
}
Splitting a module
A module that grew too big can be split into x and y while existing users upgrade in place. The original module becomes a shim: it calls the two new modules and records where each resource went.
# the original module, now a shim
module "x" {
source = "../modules/x" # holds aws_instance.a and .b
}
module "y" {
source = "../modules/y" # holds aws_instance.c
}
moved {
from = aws_instance.a
to = module.x.aws_instance.a
}
moved {
from = aws_instance.b
to = module.x.aws_instance.b
}
moved {
from = aws_instance.c
to = module.y.aws_instance.c
}
Addresses in moved are resolved relative to the module that declares them, and a module may only make statements about its own objects and its children’s. So this breaks the usual “child module is a closed box” rule, and only makes sense when one team ships all three modules together. If the target call uses count or for_each, name the instance: to = module.new[2].aws_instance.example.
What moved cannot do
- Turn a managed
resourceinto adatasource. - Move between resource types unless the provider supports migrating from the old type. Check its documentation.
- Apply to
ephemeralblocks, which are never in state.
The lifetime of a moved block
Removing a moved block is a breaking change. Any state still at the old address will plan a destroy. In a module other people consume, keep historical moves indefinitely. When an object moves twice, chain the blocks so both kinds of user can upgrade:
moved {
from = aws_instance.a
to = aws_instance.b
}
moved {
from = aws_instance.b
to = aws_instance.c
}
In a private root configuration with a known set of states, you may delete the blocks once every state has applied the version that contains them.
removed: stop managing, keep the object
Deleting a resource block plans a destroy. To hand an object over to another configuration, another tool or a human, delete the block and replace it with:
removed {
from = aws_instance.web
lifecycle {
destroy = false
}
}
The plan then shows the object being removed from state with nothing destroyed, sometimes summarised as “to forget”. Rules:
fromnames the resource without instance keys.aws_instance.web[0]is invalid.lifecycleis optional, but OpenTofu warns when it is missing. State your intent.destroy = truebehaves exactly like deleting the block, and keeps a written record that the resource once existed.
With destroy = true, a removed block can carry a destroy-time provisioner, so cleanup that lived on the old resource still runs:
removed {
from = aws_s3_bucket.example
lifecycle {
destroy = true
}
provisioner "local-exec" {
when = destroy
command = "echo 'destroying bucket ${self.bucket}'"
}
}
The provisioner runs only when both destroy = true and when = destroy are set. It can reference only self, each.key and count.index, which are what state still knows about the object.
removed is the reviewable, plan-visible form of tofu state rm. Use it in shared code and in pipelines. Keep the CLI for emergencies.
Handover recipe: moving an object to another configuration
- In the receiving configuration, add the
resourceblock and animportblock for the object. Plan: one import, no changes. - Apply the receiving side.
- In the giving configuration, replace the
resourceblock withremoved { destroy = false }. Plan: one removed from state, nothing destroyed. - Apply the giving side. Delete the
removedandimportblocks in a later change.
Applying them in the other order leaves a window where nothing manages the object. Leaving out step 3 leaves two configurations fighting over it.
deprecated: warn callers first
Refactoring a module’s interface (renaming an input or an output) needs action from callers. Variables and outputs take a deprecated attribute with a migration hint:
variable "instance_type" {
type = string
default = null
deprecated = "Use var.size instead; instance_type will be removed in v3."
}
output "public_ip" {
value = aws_instance.web.public_ip
deprecated = "Use the public_ips output (a map keyed by instance)."
}
Callers that set the variable or read the output get a warning. Output deprecation warnings appear on tofu plan and tofu apply, not on tofu validate, so CI that only validates won’t show them.
Choosing the tool
| You want to | Use | Not |
|---|---|---|
| Rename a resource or module call | moved |
tofu state mv in a pipeline |
Switch count to for_each |
moved per instance |
Hoping keys match |
| Split a module | Shim module plus moved |
Asking every caller to run state commands |
| Stop managing but keep | removed with destroy = false |
Deleting the block |
| Delete, with cleanup | removed with destroy = true and a provisioner |
Leaving a provisioner on a dead resource |
| Rename a module input or output | deprecated for one release, then remove |
A silent breaking change |
Diagnosis
| Plan shows | Likely cause | Fix |
|---|---|---|
-/+ or destroy plus create after a rename |
No moved block, or from typo |
Add or fix moved and re-plan until it reports “has moved to” |
Every instance replaced after a count to for_each change |
Instance-level moves missing | One moved per index-to-key mapping |
A destroy after deleting old moved blocks |
Some state was still at the old address | Restore the blocks |
Error on removed with [0] |
Instance key in from |
Use the bare resource address |
Warning: removed without lifecycle |
Intent not stated | Add lifecycle { destroy = ... } |
| No deprecation warnings in CI | Only tofu validate runs |
Run tofu plan in CI |
Operational care
- Make “no destroys” a review gate for refactor PRs.
tofu show -jsonon a saved plan makes it scriptable. - Put
prevent_destroyon stateful resources. It turns a missedmovedinto an error instead of an outage. - Keep refactors separate from functional changes so the plan diff is only address moves.
- For shared modules, record moves in the changelog and keep the blocks. For root configurations, put the cleanup PR on a calendar.
Practice
- A root module has
aws_instance.appwithcount = 3. You wantfor_eachover["blue", "green", "canary"]. Write themovedblocks. - You’re moving
aws_route53_zone.mainfrom a monolith into a dedicated DNS configuration. Write both sides of the handover and give the apply order. - Your published module renames output
vpctonetwork. Plan the deprecation over two releases.
Recall card
- State is keyed by address. Any rename without
movedis destroy plus create. - A move without instance keys covers all instances. A move with a key covers that instance only.
movedresolves relative to its module and is a permanent upgrade path in shared modules. Chain repeated moves.removed { lifecycle { destroy = false } }forgets without destroying. No instance keys. A destroy-time provisioner runs only withdestroy = true.deprecatedon variables and outputs warns callers. Output warnings appear on plan and apply, not validate.
Sources
- OpenTofu, Refactoring
- OpenTofu, Resource blocks: Removing Resources
- OpenTofu, Input variables