| Tested on | Ubuntu 26.04 LTS (Resolute Raccoon) |
|---|---|
| Package | terraform 1.15.8-1hashicorp/local 2.9.0 (removed demo) |
| Applies to | Any host with Terraform installed |
| Lab environment | Single Ubuntu VM with Terraform — Terraform lab environment on Ubuntu |
| Privilege | Normal user |
| Scope | Declarative refactoring — moved block from and to addresses, resource rename, move into a child module, removed block with destroy false, external verification, moved vs state mv and removed vs state rm comparison, brief for_each rename. Does not cover terraform state CLI depth, import, module authoring, count or for_each tutorials, manual state JSON editing, or legacy migration mechanisms. |
| Related guides | terraform state commands Terraform state explained Terraform modules Terraform count and for_each Terraform Associate certification course |
Renaming a resource block or moving it into a module changes its Terraform address. Without migration metadata, Terraform treats the old and new addresses as unrelated objects — the plan proposes destroy-and-create even when the real infrastructure never changed.
# before
resource "terraform_data" "server" {
input = "rename lab"
}
# after rename — same input, new label
resource "terraform_data" "application" {
input = "rename lab"
}moved and removed blocks record these refactoring decisions in configuration: moved maps an old address to a new address, while removed declares that Terraform should stop managing an existing address. Examples run under ~/terraform-labs/terraform-moved-removed-block/ with terraform_data and local_file — no cloud credentials required.
Rename a resource with a moved block
Create the rename lab directory:
mkdir -p ~/terraform-labs/terraform-moved-removed-block/renameWork inside it for the rename walkthrough:
cd ~/terraform-labs/terraform-moved-removed-block/renameAdd a minimal resource in main.tf:
resource "terraform_data" "server" {
input = "rename lab"
}Initialize the rename working directory:
terraform initApply so terraform_data.server exists in state:
terraform apply -auto-approveRename the block label to application — keep the same input value:
resource "terraform_data" "application" {
input = "rename lab"
}Plan before adding moved — Terraform still tracks terraform_data.server:
terraform planSample output:
# terraform_data.application will be created
+ resource "terraform_data" "application" { ... }
# terraform_data.server will be destroyed
- resource "terraform_data" "server" {
- id = "224d38c6-d0dd-d24e-6370-97ad386b464d" -> null
}
Plan: 1 to add, 0 to change, 1 to destroy.That destroy-and-create pair is unnecessary when only the label changed. Add a moved block above the resource:
moved {
from = terraform_data.server
to = terraform_data.application
}
resource "terraform_data" "application" {
input = "rename lab"
}Plan again — Terraform records the address change instead of replacing the object:
terraform planSample output:
# terraform_data.server has moved to terraform_data.application
resource "terraform_data" "application" {
id = "224d38c6-d0dd-d24e-6370-97ad386b464d"
}
Plan: 0 to add, 0 to change, 0 to destroy.Apply so state permanently uses the new address:
terraform apply -auto-approveList state to confirm only the new address remains:
terraform state listSample output:
terraform_data.applicationThe same underlying terraform_data instance is now tracked under application instead of server.
Move resources into a module
Module refactors change addresses from a root resource to a path inside a child module:
terraform_data.example
↓
module.application.terraform_data.exampleCreate the module-move tree:
mkdir -p ~/terraform-labs/terraform-moved-removed-block/module-move/modules/applicationSwitch into the module-move root:
cd ~/terraform-labs/terraform-moved-removed-block/module-moveStart with a root-level resource in main.tf:
resource "terraform_data" "example" {
input = "module move lab"
}
output "example_id" {
value = terraform_data.example.id
}Create the child module in modules/application/main.tf:
resource "terraform_data" "example" {
input = "module move lab"
}Expose the id from modules/application/outputs.tf:
output "example_id" {
value = terraform_data.example.id
}Initialize the root-only layout:
terraform initApply so the root resource exists before the module refactor:
terraform apply -auto-approveRefactor configuration to call the module — remove the root resource block and wire the output through the module:
module "application" {
source = "./modules/application"
}
output "example_id" {
value = module.application.example_id
}Re-run terraform init so Terraform initializes the newly referenced local child module:
terraform initPlan before moved — Terraform sees a root destroy and a module create:
terraform planSample output:
# terraform_data.example will be destroyed
- resource "terraform_data" "example" { id = "383e586c-..." ... }
# module.application.terraform_data.example will be created
+ resource "terraform_data" "example" { ... }
Plan: 1 to add, 0 to change, 1 to destroy.
Changes to Outputs:
~ example_id = "383e586c-..." -> (known after apply)Add the module move to main.tf:
moved {
from = terraform_data.example
to = module.application.terraform_data.example
}
module "application" {
source = "./modules/application"
}
output "example_id" {
value = module.application.example_id
}Plan after moved:
terraform planSample output:
# terraform_data.example has moved to module.application.terraform_data.example
resource "terraform_data" "example" {
id = "383e586c-2d5e-6080-1032-c3b3bdfaa77a"
}
Plan: 0 to add, 0 to change, 0 to destroy.Apply the module move:
terraform apply -auto-approveConfirm the output id is unchanged after the address change:
terraform output example_idState now lists module.application.terraform_data.example instead of the root address.
Stop managing a resource with a removed block
Sometimes you want Terraform to stop managing an object without deleting it — handoff to another team, retention after a tool migration, or leaving a file on disk. The removed block declares that transition in configuration.
Create the removed demo directory:
mkdir -p ~/terraform-labs/terraform-moved-removed-block/removedMove into it for the stop-managing walkthrough:
cd ~/terraform-labs/terraform-moved-removed-block/removedDeclare hashicorp/local in versions.tf:
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}Create the file in main.tf:
resource "local_file" "keep" {
content = "survives removed"
filename = "${path.module}/keep.txt"
}Initialize the removed demo:
terraform initCreate keep.txt through Terraform before you stop managing it:
terraform apply -auto-approveReplace the resource block with a removed block — configuration keeps only the removal declaration:
removed {
from = local_file.keep
lifecycle {
destroy = false
}
}Plan — Terraform drops management without calling destroy:
terraform planSample output:
# local_file.keep will no longer be managed by Terraform, but will not be destroyed
# (destroy = false is set in the configuration)
. resource "local_file" "keep" { ... }
Plan: 0 to add, 0 to change, 0 to destroy.
Warning: Some objects will no longer be managed by Terraform
If you apply this plan, Terraform will discard its tracking information for
the following objects, but it will not remove them from your infrastructure:
- local_file.keepApply the removal from management:
terraform apply -auto-approveConfirm the file still exists on disk — destroy = false means the provider delete API was not called:
ls -la keep.txtSample output:
-rwxr-xr-x 1 user user 16 Aug 12 10:20 keep.txtThe metadata line confirms the file is still on disk. Read the content to verify nothing was truncated:
cat keep.txtSample output:
survives removedState no longer tracks the resource:
terraform state listThe command prints nothing — local_file.keep is gone from state while keep.txt remains on disk.
removed with destroy = false is not the same as deleting the resource block and running apply without a removed declaration — that path can plan destruction. Declare the removal explicitly when infrastructure should survive.
moved vs state mv and removed vs state rm
Declarative blocks and imperative CLI commands solve similar problems at different layers:
moved
→ declarative refactor recorded in configuration
terraform state mv
→ imperative one-time state operation
removed
→ declarative removal from management
terraform state rm
→ imperative one-time state removal| Mechanism | Lives in | Best for |
|---|---|---|
moved |
.tf configuration |
Renames and address changes every teammate and CI environment should apply consistently |
terraform state mv |
CLI session | Emergency repairs, labs, or one-off fixes when you cannot wait for a configuration rollout |
removed |
.tf configuration |
Stopping management with an explicit destroy choice recorded in Git |
terraform state rm |
CLI session | Immediate state-only forget when configuration already changed or for surgical CLI work |
Declarative blocks travel through version control and terraform plan in every workspace. That repeatability is why teams prefer moved and removed for refactors that must land the same way in dev, staging, and production. The terraform state commands lesson covers imperative mv and rm in depth — including when re-applying after state rm may plan a create instead of reconnecting to existing infrastructure.
Keep moved blocks in configuration until every environment that might still reference the old address has applied the migration at least once. Removing the block too early can strand stale state on addresses your configuration no longer declares.
Refactor for_each resource addresses
moved also works when a for_each resource changes its block label. In foreach-move/, start with:
resource "terraform_data" "nodes" {
for_each = toset(["web", "api"])
input = "foreach ${each.key}"
}Apply, then rename the block to instances and add:
moved {
from = terraform_data.nodes
to = terraform_data.instances
}
resource "terraform_data" "instances" {
for_each = toset(["web", "api"])
input = "foreach ${each.key}"
}Plan reports one move per instance key — terraform_data.nodes["api"] to terraform_data.instances["api"], and the same for web — then settles on no changes. Individual key renames inside the map need explicit from and to addresses for each affected instance; see Terraform count and for_each for address syntax, not repetition here.
Common refactoring mistakes
| Symptom | Likely cause | Fix |
|---|---|---|
| Destroy + create on a label rename | Renamed block without moved |
Add moved { from = ... to = ... } and re-plan |
| Wrong resource replaced or move ignored | Incorrect from or to address |
Match exact addresses from terraform state list |
| Real infrastructure deleted on refactor | Deleted resource block without removed |
Use removed with destroy = false when objects should survive |
removed still plans destroy |
lifecycle { destroy = true } or missing removed |
Set destroy = false explicitly when retention is required |
| Teammate still sees old address errors | moved block removed before all envs migrated |
Keep moved until every workspace applied once |
| One machine fixed, others still broken | Used state mv instead of moved |
Add declarative moved so CI and other environments get the same migration |
The before-and-after plan evidence matters more than memorizing block syntax. For moved, confirm the plan reports the old address moving to the new address without replacement. For removed with destroy = false, confirm Terraform explicitly says the object will no longer be managed but will not be destroyed. The 0 to add, 0 to change, 0 to destroy summary counts infrastructure actions; applying a removed plan still changes Terraform state.
References
Summary
moved and removed blocks declare address changes in configuration so Terraform refactors without unnecessary destroy-and-create cycles. You renamed terraform_data.server to terraform_data.application and saw the plan flip from one add and one destroy to a recorded move with no infrastructure change. The module exercise moved terraform_data.example into module.application.terraform_data.example the same way — professional refactors often cross module boundaries, and moved carries the instance identity with them.
removed with lifecycle { destroy = false } stops management while leaving keep.txt on disk — the distinction from destroy and from bare configuration deletion is worth internalizing before you touch production state. Declarative blocks beat one-off CLI edits when every environment must apply the same migration; imperative state mv and state rm remain valid for surgical repairs, as the state commands lesson covers.
Keep moved blocks until all workspaces have applied the migration, quote indexed addresses carefully when debugging, and plan before apply whenever you change resource paths. Import and deeper module authoring live in sibling lessons on this course track.

