Refactoring Terraform State Without Destroying Everything

Refactoring Terraform state with moved, removed and import blocks

Reading time: 2 minutes, 38 seconds

The reason people leave bad Terraform code bad is fear.

You want to rename a resource from azurerm_storage_account.sa to something sensible. You want to pull three copy-pasted resources into a reusable module. You want to split a 2,000-line monolith into layers. Every one of these is a good idea, and every one of them makes Terraform plan the same terrifying thing: destroy the old address, create a new one. On a storage account holding production data, that plan output ends the refactor before it starts.

The fear is rational, but it is also outdated. Terraform’s state is keyed by resource address, not by the real-world object. When you rename or move a resource, the underlying Azure resource has not changed at all — only the address Terraform files it under. Modern Terraform lets you tell it about that address change in code, reviewed in a PR, with no destroy anywhere in the plan.

Here is the toolkit, from the one everyone should use to the one everyone reaches for in a panic.

moved — rename and restructure, declaratively

The moved block is the one that changes how you work. It records that a resource has changed address, so Terraform updates state instead of recreating:

moved {
from = "azurerm_storage_account.sa"
to = "azurerm_storage_account.data"
}

On the next plan, instead of destroy-and-create, you get:

  # azurerm_storage_account.sa has moved to azurerm_storage_account.data

No destruction. The same applies when you pull resources into a module:

moved {
from = "azurerm_storage_account.data"
to = "module.storage.azurerm_storage_account.this"
}

Why this beats the old terraform state mv command: it lives in your configuration, so it is version-controlled, code-reviewed, and it runs automatically for every teammate and every pipeline. The CLI command was imperative, ran once, on one machine, with no record. A moved block is the same operation made declarative and auditable. You can delete the block a release or two later once everyone’s state has caught up.

import — adopt existing infrastructure into code

The reverse problem: a resource exists in Azure — created by hand, by a script, by another team — and you want Terraform to manage it without recreating it. The import block (Terraform 1.5+) does this declaratively too:

import {
to = azurerm_resource_group.legacy
id = "/subscriptions/0000.../resourceGroups/rg-legacy"
}
resource "azurerm_resource_group" "legacy" {
name = "rg-legacy"
location = "eastus"
}

terraform plan shows what would be imported and whether your config matches the live resource — so you can reconcile the two before committing. This replaces the old imperative terraform import command, and again the win is the same: it is in code, it is reviewable, and plan previews it. Generate a starting config with terraform plan -generate-config-out=generated.tf and then clean it up rather than hand-writing every argument.

removed — stop managing without destroying

The newest piece (Terraform 1.7+) solves the awkward case: you want a resource out of your Terraform but left running in Azure. Deleting the resource block alone tells Terraform to destroy it. The removed block tells Terraform to forget it instead:

removed {
from = azurerm_storage_account.data
lifecycle {
destroy = false
}
}

This drops the resource from state while leaving the real storage account untouched — the declarative successor to terraform state rm. Essential when you are handing a resource to another team’s state, or carving a stack into pieces.

A real refactor: monolith into layers

Put these together and a scary migration becomes a routine PR. Say you are splitting one root module into a network layer and an app layer. The app resources need to leave the monolith’s state and join the new layer’s state. The safe sequence:

  1. In the new app layer, add import blocks (or moved, if you are re-rooting within the same state) for each resource, with matching resource blocks
  2. In the old monolith, add removed { ... lifecycle { destroy = false } } for those same resources
  3. plan both and read them carefully: the new layer should show imports only, the old should show removals from state onlyneither should show a single destroy of a real resource
  4. Apply the new layer, then the old
  5. Delete the import / removed blocks in a follow-up once both states have settled

The discipline that makes this safe is step 3: if any plan contains destroy of a live resource, stop. A correct state refactor moves addresses around and touches zero real infrastructure. The moment you see a real destroy, the addresses don’t line up and you have more reconciling to do.

The mindset shift

State surgery used to be a sweaty CLI session — terraform state mv, terraform state rm, terraform import — run by one person, unreviewed, on a Friday, with a backup of the state file and a prayer.

The moved, import, and removed blocks turn all of it into code in a pull request: previewed by plan, reviewed by a colleague, applied by the pipeline, identical for everyone. The infrastructure never moves; only Terraform’s idea of where it is filed changes. Once you internalise that — that a rename is a bookkeeping change, not a rebuild — the fear that keeps your modules ugly simply goes away.

Facebook
Twitter
LinkedIn
Email

Leave a Reply

Get new articles by email

Practical Cloud, DevOps and AI walkthroughs

We don’t spam! Read our privacy policy for more info.

Discover more from HandsOnAzure

Subscribe now to keep reading and get access to the full archive.

Continue reading