Chapter 4: State on GCS
Topic 26

Refactoring with moved Blocks

Refactoring

Rename google_storage_bucket.raw to google_storage_bucket.events_raw and Terraform, seeing an old address disappear and a new one appear, plans to destroy and recreate the bucket. For a bucket holding pipeline data, that is a disaster: the rename you meant as a cosmetic change becomes a data loss. The moved block is the fix.

A moved block tells Terraform that the resource did not disappear — it just changed address. With it, the plan becomes a no-op state rename instead of a destroy-and-create, and not a single byte of the bucket is touched. It is the difference between a safe refactor and an accidental deletion, and it belongs in every rename and every move into or out of a module.

Why Renames Look Like Destroys

Terraform keys state by resource address, not by GCP identity. When you change the address in HCL, the old address google_storage_bucket.raw now has no config behind it — which reads as "destroy this" — while the new address google_storage_bucket.events_raw has no state behind it, which reads as "create this." Terraform sees a deletion and an addition, even though the same bucket is meant by both.

Nothing about the actual GCP resource has changed; the only thing that moved is the label in your config. But Terraform has no way to know that the two addresses refer to one object unless you tell it — and that is exactly what the moved block does.

The moved Block

A moved block records the rename in config: it names the from address and the to address, and the next plan shows a state move with no resource change. Because it lives in the config and is committed, every teammate and CI run replays the same rename automatically when they apply — nobody has to be told to run anything by hand.

Renaming a bucket without recreating it
moved {
  from = google_storage_bucket.raw
  to   = google_storage_bucket.events_raw
}

resource "google_storage_bucket" "events_raw" {   # the new address
  name     = "hatch-events-raw"
  location = "US"
}

The to address must match the renamed resource exactly. With the block in place, the plan reports a move and zero changes to the bucket itself — Terraform rewrites the address in state and leaves the GCP object alone.

moved vs terraform state mv

Both moved and the imperative terraform state mv rewrite an address in state. The difference is who sees it. A moved block is declarative: it appears in the plan diff, is reviewable in a pull request, and is replayed automatically by anyone who applies the config. state mv is a one-shot you run locally — it moves the address in your state and nobody else's, with no record in the config for others to replay.

For a shared config, that gap is the whole problem. Run state mv on your machine, and your state is moved while the committed config and every teammate's state still show the old address — so the next person's plan proposes the destroy-and-create you thought you had prevented. For anything in a shared repo, prefer moved.

moved block vs state mv
moved block
Declarative — committed to config, shows in the plan, replays for everyone who applies.
state mv
Imperative — a one-shot run locally; moves your state only, with no record others replay.

Refactoring Into and Out of Modules

A resource's address also changes when you pull it into a module or push it out. Extract the Hatch ingest resources into a module and google_storage_bucket.raw becomes module.ingest.google_storage_bucket.raw — a new address, which without a moved block reads as destroy-the-old, create-the-new. The moved block handles the module-prefix change exactly as it handles a flat rename.

This is what makes "extract a module from existing resources" a safe operation rather than a rebuild. You write the module, write a moved block per resource mapping the old flat address to the new module-qualified one, and the plan is a set of state moves with no infrastructure change. The same works in reverse when you inline a module back into the root.

Lifecycle and Cleanup

moved blocks are cheap to leave in place — they document the history of a refactor and cost nothing on a plan once applied. But they are not permanent fixtures: once every environment's state has applied the move, you can remove them in a later cleanup. The one rule is to keep them until every state has caught up, because a stale state that never saw the move will run the destroy-and-create the block was preventing.

For risky refactors on data-bearing resources, pair the move with a seatbelt. Put prevent_destroy on the state bucket, hatch-events-raw, and any database, so that if a moved block is missing or mistyped, the plan fails loudly on the attempted destroy instead of quietly deleting data. The lifecycle guard turns a missed moved from an outage into an error message.

moved Block vs terraform state mv

moved block — a declarative block committed to config. It appears in the plan diff, applies automatically for everyone, and is the right tool for shared repos and CI, where the refactor must replay for the whole team.

terraform state mv — an imperative local command that does the same rename once on your machine, with no record others replay. Use it only for quick local fixes, never for a team refactor in a shared config.

Common Mistakes
  • Renaming a resource or moving it into a module without a moved block — the plan proposes destroy/recreate, and on a bucket with objects or a database you delete real data to perform a rename.
  • Using terraform state mv on a shared config and not telling anyone — your local state is moved but the committed config and everyone else's state aren't, so the next teammate's plan still wants to destroy/recreate.
  • Writing a moved whose to address doesn't match the new HCL address exactly — the move doesn't apply and the destroy/recreate reappears.
  • Deleting moved blocks before every state has applied the rename — a stale CI state that never saw the move runs the destroy/recreate you thought you'd prevented.
  • Skipping prevent_destroy on data-bearing resources during a refactor — a missed or mistyped moved then deletes data silently instead of failing the plan.
Best Practices
  • Add a moved block whenever you rename a resource or move it into or out of a module, so the refactor is a state rename, not a destroy/recreate.
  • Prefer moved over terraform state mv for anything in a shared repo, because it is reviewable and replays automatically for the whole team and CI.
  • Put prevent_destroy on data-bearing resources (the state bucket, hatch-events-raw, databases) so a missed moved fails loudly instead of deleting data.
  • Keep moved blocks until every environment's state has applied the move, then remove them in a later cleanup.
  • Confirm the post-refactor plan shows state moves with zero resource changes before applying, which proves the moved addresses line up with the new HCL.
Comparable tools AWS provider identical moved block and state mv Pulumi aliases for resource renames gcloud no analog — it has no state to rename

Knowledge Check

Why does renaming a resource in HCL plan as a destroy-and-recreate without a moved block?

  • Terraform keys state by address, so the old address looks like a deletion and the new one like a creation
  • Renaming a resource always forces a hard replacement at the underlying GCP API level, because the provider treats the resource's name as an immutable field on the live object
  • Terraform fundamentally cannot rename buckets in place, so it can only ever recreate them
  • The state file's lineage UUID is regenerated on every single resource rename

Why is a moved block preferred over terraform state mv for a shared config?

  • It is committed to config, so it is reviewable and replays automatically for every teammate and CI run
  • state mv cannot move resources into or out of modules, so only a moved block can
  • moved physically relocates the live GCP object to a new address in the cloud, whereas state mv only edits text on disk and never reaches the API
  • state mv has been fully deprecated and no longer works in current Terraform versions

How does a moved block make extracting a module from existing resources safe?

  • It maps the old flat address to the new module-qualified address, so the plan is a state move with no infrastructure change
  • It copies each resource into the new module as a fresh object and then deletes the originals cleanly behind the scenes, so the live infrastructure is rebuilt under the module
  • It temporarily disables the module's provider during the move so that nothing can be destroyed
  • It only ever works for simple in-place renames, never for extracting resources into a module

Why should you keep a moved block until every environment's state has applied it?

  • A state that never saw the move will still run the destroy-and-recreate the block was preventing
  • The block permanently rewires the resource's behavior and can never be safely removed afterward
  • Removing it corrupts the lineage UUID of every state that previously applied the block
  • Terraform bills a per-block charge for each moved block until it is applied everywhere

You got correct