Refactoring Terraform: Moving Resources Without Destroying Them

Anyone who has worked on a Terraform codebase for any length of time eventually hits the same problem: the code you wrote six months ago no longer fits how the project has grown. Resources sit in the wrong file, naming conventions have drifted, and what started as a single main.tf now needs to be split into modules. The temptation is to leave it alone, because refactoring Terraform has a reputation for ending in tears, or worse, a destroyed production resource.
The good news is that Terraform has matured significantly in this area. Between the moved block, the removed block, and the terraform state CLI, you can restructure a codebase without Azure ever noticing. In this post, I'll walkthrough the approaches I use when refactoring Terraform on Azure, when to use each one, and how to do it safely.
Why moving resources is tricky
Terraform tracks every resource by an address in the state file, something like azurerm_storage_account.main. When you rename the resource in your code, Terraform doesn't see a rename, it sees one resource being removed and another being created. On a plan, it appears as a destroy followed by a create, which on a storage account or a key vault is the last thing you want.
The job of refactoring safely is really just the job of keeping the state file in sync with whatever the code now says, without Azure being touched.
Option 1: The moved block
The moved block was introduced in Terraform 1.1 and is the approach I reach for first. It lives in your code, gets committed alongside the change, and anyone running a plan will see the same result.
Let's say you have a storage account defined like this:
resource "azurerm_storage_account" "main" {
name = "stjamescookdev001"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
account_tier = "Standard"
account_replication_type = "LRS"
}
You decide to rename it to something more descriptive, say azurerm_storage_account.diagnostics. Instead of just changing the name and crossing your fingers, you add a moved block:
resource "azurerm_storage_account" "diagnostics" {
name = "stjamescookdev001"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
account_tier = "Standard"
account_replication_type = "LRS"
}
moved {
from = azurerm_storage_account.main
to = azurerm_storage_account.diagnostics
}
When you now run terraform plan, Terraform reads the moved block, matches the old address to the new one in the state, and reports no change to Azure. The plan output will call it out as a move rather than a destroy and create, which is exactly what you want to see.
Option 2: Moving into a module
The moved block really shines when you start pulling resources into modules. If you take the same storage account and lift it into a new diagnostics module, the address changes from azurerm_storage_account.diagnostics to module.diagnostics.azurerm_storage_account.this.
The refactor looks like this:
module "diagnostics" {
source = "./modules/diagnostics"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
}
moved {
from = azurerm_storage_account.diagnostics
to = module.diagnostics.azurerm_storage_account.this
}
The moved block still lives in the root module, pointing into the new module path. Run a plan and it should come back clean. This is genuinely one of my favourite features in Terraform, as before 1.1 you had to do this surgically with the state CLI, which was never fun on a shared state file.
Option 3: The terraform state mv CLI
The CLI command terraform state mv is the original way to do this, and it still has its place. The big difference is that it modifies state directly, so no one running a plan afterwards sees any record of what you did. For that reason I only use it when the moved block doesn't cover the scenario, usually when I'm rescuing a state file that has drifted from the code or moving resources between two separate state files.
The command takes a source address and a destination address:
terraform state mv azurerm_storage_account.main azurerm_storage_account.diagnostics
For moves across state files, you can use the -state and -state-out flags:
terraform state mv -state=old.tfstate -state-out=new.tfstate \
azurerm_storage_account.main azurerm_storage_account.diagnostics
One thing to remember with remote backends, like Terraform Cloud or an Azure Storage Account, is that the state is locked during the operation and you're writing a new version of the state file. Always take a backup first. You can pull the state locally with terraform state pull > backup.tfstate before doing anything.
Option 4: The removed block
A common thing I see is people wanting to drop a resource from Terraform's management without actually deleting it in Azure. Maybe it's being taken over by another team, or you're moving it to a different state file. Terraform 1.7 introduced the removed block for exactly this.
removed {
from = azurerm_storage_account.diagnostics
lifecycle {
destroy = false
}
}
With destroy = false, the resource is removed from state but left alone in Azure. Before this block existed, the only option was terraform state rm, which worked but left no trace in code of what you did.
Testing the refactor safely
Whatever approach you pick, the rule I follow is the same: never trust a refactor until you've seen a clean plan. The workflow I use is:
Make the code change and add the
movedorremovedblockRun
terraform planand read the output carefullyLook for any
# ... will be destroyedlines, as those are the warning signsIf the plan is clean, or only shows moves, apply it
If you're working with a remote state backend, it's also worth running the plan on a branch through your CI/CD so the team can see it before anything merges. Refactors are one of those changes where a second pair of eyes on the plan output is genuinely valuable. Maybe if you are using GitHub Copilot, ask it to review to exclude human oversight/errors.
My thoughts on refactoring
Terraform refactors used to be something I'd avoid unless I had to. The tooling has come a long way since the 1.1 release, and now I treat refactoring as a normal part of keeping a codebase healthy rather than a last resort. The moved block in particular has removed most of the fear, because the change lives in code, gets reviewed like anything else, and is reversible if something looks off in the plan.
If your Azure Terraform codebase has been around for a while and you've been putting off a cleanup, the tools are here. Start with a small rename, get comfortable with the plan output, and work your way up to module extractions.





