Why moved
Before TF 1.1, renaming a resource meant terraform state mv by hand. That
works, but:
- Only one person ran it, so everyone else's state drifted apart (with a
local backend) or they had to
pullquickly. - There was no trace in git: the HCL changed, but why it didn't fall into destroy + create was known only to whoever typed the command in their terminal.
- In a PR review you could not tell "this is a refactor" from "this is a new resource".
Since TF 1.1, you have the moved block. It is declarative, it lives in
HCL, it shows up in the diff, and the plan reports it explicitly as a
move.
Minimal example: rename
Before:
resource "aws_s3_bucket" "logs" {bucket = "linuxlab-logs"
}
What you want:
resource "aws_s3_bucket" "log_storage" {bucket = "linuxlab-logs"
}
moved {from = aws_s3_bucket.logs
to = aws_s3_bucket.log_storage
}
Run plan:
# aws_s3_bucket.logs has moved to aws_s3_bucket.log_storage
bucket = "linuxlab-logs"
...
Plan: 0 to add, 0 to change, 0 to destroy.
Apply, and nothing in the cloud changes. The state is updated.
Without the moved block, the plan would show:
- aws_s3_bucket.logs will be destroyed
+ aws_s3_bucket.log_storage will be created
That is a destroy + create, and you lose data. moved prevents it.
Cases
Rename
The most common one, shown above. After the apply you can delete the
moved block (though many teams keep it in the HCL as documentation:
"this was renamed at some point").
Moving into a module
You had a resource in root and pulled it into a module:
# root
module "buckets" {source = "./modules/buckets"
}
moved {from = aws_s3_bucket.logs
to = module.buckets.aws_s3_bucket.logs
}
Inside the module:
# modules/buckets/main.tf
resource "aws_s3_bucket" "logs" {bucket = "linuxlab-logs"
}
terraform plan reports 0 to add, 0 to change, 0 to destroy. The
resource moved in state from the root level into module.buckets.
Moving out of a module
The reverse: you break a module apart and the resource returns to root:
moved {from = module.buckets.aws_s3_bucket.logs
to = aws_s3_bucket.logs
}
count to for_each
One of the most useful cases. You used to have:
resource "aws_s3_bucket" "logs" {count = 3
bucket = "linuxlab-logs-${count.index}"}
The addresses in state are aws_s3_bucket.logs[0], [1], [2].
You want for_each for stable keys:
variable "log_levels" {type = set(string)
default = ["debug", "info", "error"]
}
resource "aws_s3_bucket" "logs" {for_each = var.log_levels
bucket = "linuxlab-logs-${each.key}"}
moved {from = aws_s3_bucket.logs[0]
to = aws_s3_bucket.logs["debug"]
}
moved {from = aws_s3_bucket.logs[1]
to = aws_s3_bucket.logs["info"]
}
moved {from = aws_s3_bucket.logs[2]
to = aws_s3_bucket.logs["error"]
}
Watch out: the bucket names change. linuxlab-logs-0 becomes
linuxlab-logs-debug. If the buckets already exist with numeric names,
you either keep the old names in the HCL or accept the bucket = change
(which for S3 means destroy + create, because the name is immutable).
Usually you do count -> for_each before the resources are created.
If they already exist, keep the names in the HCL the same as in the cloud.
What moved can and cannot do
| Can | Cannot |
|---|---|
| Rename a resource in state | Change the resource type (aws_s3_bucket to aws_s3_bucket_v2) |
| Move into a module or out of one | Change the provider |
| Switch count to for_each and back | Change the provider configuration |
| Move between different roots | Merge several resources into one |
To change the type, use destroy + create or import. To change the provider, it is usually destroy + create as well, because under the hood that is a different API.
What it pairs with
- The
removedblock (tf-removed-block).movedrelocates,removeddrops a resource from state. The two often appear side by side in one PR. - The
importblock (tf-state-import): sometimes you change the HCL and adopt an existing resource at the same time. lifecycle.ignore_changes: moved does not cancel ignore_changes; those rules keep applying after the move.
Pitfalls
-
moved is not removed automatically. After the apply, the block stays in the HCL. You can delete it by hand; some teams keep it in archive files (
refactoring.tf) for the audit trail. It is safe to remove a sprint or two after the migration. -
moved does not move data. This is a state-only operation. If you have
aws_s3_bucket.logswith 100GB of data and you run amoved, the data stays in the same bucket. Only the address in state changes. -
Chains of moved work.
A -> B,B -> Cin one HCL file. Terraform sorts it out. But it is poor style: when you can, write one directA -> C. -
moved across providers does not work.
aws.usandaws.euare different provider instances. A move between them is destroy + create (a bucket in one region does not "travel" to another). -
moved does not work across types.
aws_s3_bucket.x -> aws_s3_bucket_acl.x, no. The resource type must match in from and to. -
The moved block is checked at init. A typo in a resource name and
terraform planfails before the plan even runs. -
It is ideal for PR review. The diff shows it plainly: "this resource was here, now it is there, this is a refactor, not a destroy." It explains the intent. That is why it is preferable to a manual
state mv.