linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Introduction
  • Lessons
  • How it works
  • Knowledge base
  • Cheat sheet
  • Capstone
  • Interview prep
home/terraform/kb/Refactoring/tf-moved-block

kb/refactoring ── Refactoring ── intermediate

The moved block: rename without destroy

`moved { from = ..., to = ... }` in HCL declaratively tells Terraform: "this resource used to live at one address and now lives at another, the cloud object is the same." The plan shows a "move", not a "destroy + create". It arrived in TF 1.1. It replaces the manual `terraform state mv`, leaves a trace in git, repeats for everyone on the team, and shows up in the diff.

view as markdownaka: terraform-moved, terraform-moved-block

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 pull quickly.
  • 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:

hcl
resource "aws_s3_bucket" "logs" {
  bucket = "linuxlab-logs"
}

What you want:

hcl
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:

hcl
# root
module "buckets" {
  source = "./modules/buckets"
}
moved {
  from = aws_s3_bucket.logs
  to   = module.buckets.aws_s3_bucket.logs
}

Inside the module:

hcl
# 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:

hcl
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:

hcl
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:

hcl
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

CanCannot
Rename a resource in stateChange the resource type (aws_s3_bucket to aws_s3_bucket_v2)
Move into a module or out of oneChange the provider
Switch count to for_each and backChange the provider configuration
Move between different rootsMerge 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 removed block (tf-removed-block). moved relocates, removed drops a resource from state. The two often appear side by side in one PR.
  • The import block (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.logs with 100GB of data and you run a moved, the data stays in the same bucket. Only the address in state changes.

  • Chains of moved work. A -> B, B -> C in one HCL file. Terraform sorts it out. But it is poor style: when you can, write one direct A -> C.

  • moved across providers does not work. aws.us and aws.eu are 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 plan fails 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.

§ команды

bash
terraform plan

With a moved block: shows 'has moved' instead of destroy + create.

bash
terraform apply

Applies the move. Nothing in the cloud changes. The state is updated.

bash
terraform state list

Check after apply: the address has changed.

bash
git log -p main.tf

The refactor leaves a trace in git. A year later someone asks 'why did logs become log_storage': the answer is in the diff.

§ см. также

  • tf-state-manipulationstate mv, state rm, state pull/push: manual operations`terraform state mv` renames a resource address in state without destroy/recreate. `terraform state rm` removes a resource from state but not from the cloud. `terraform state pull/push` downloads or uploads state as a file. All four are sharp operations; do them with a backup and a clear reason. For declarative alternatives, see [[tf-moved-block]] and [[tf-removed-block]].
  • tf-refactor-patternsRefactoring patterns: count to for_each, split files, extract moduleLarge configs turn into spaghetti. The core refactoring patterns are: count to for_each (stable keys), splitting files by domain (network/compute/storage), extracting a repeated block into a module, merging small resources into a composite one, and removing dead imports. Each pattern is covered step by step, with a `plan` check at every step.
  • tf-removed-blockremoved block: drop a resource from state, keep it in the cloud`removed { from = ..., lifecycle { destroy = false } }` tells Terraform declaratively: remove this resource from management, but do not touch it in the cloud. The block was introduced in TF 1.7 and replaces the manual `terraform state rm` command. With `destroy = true` it behaves like an ordinary resource deletion from HCL.
  • tf-module-basicsModule: a reusable piece of infrastructureA module is any directory that contains `.tf` files and can be referenced via a `module` block. The root module is the directory where you run terraform. Child modules are the ones being called. The module contract: input variables (what it accepts), output values (what it exposes). Everything else is an implementation detail.
  • tf-count-for-eachcount and for_each: many resources from one blockcount creates N identical resources by index 0..N-1. for_each creates resources keyed from a set or map. Rule of thumb: count for identical copies, for_each when each one has its own settings. When in doubt, use for_each.
Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies