Refactoring & State Evolution
Evolve Terraform safely - moved and removed blocks, import blocks, cross-stack references, backend migration, provider replacement, and the dependency lock file
Refactoring & State Evolution
Terraform identifies a resource by its address (aws_instance.web), not by what it points to in the cloud. Rename the address and Terraform sees "the old one is gone, a new one appeared" — it plans to destroy and recreate. Refactoring is the discipline of changing your code's shape without that destruction.
The old way was imperative state surgery (terraform state mv, terraform state rm) — run by hand, off the record, easy to get wrong. Modern Terraform makes refactors declarative and reviewable: you write the intent in HCL, it shows up in the plan, a teammate approves it in the PR.
moved: Rename Without Recreating
A moved block tells Terraform that an address changed identity, so it updates state instead of replacing the resource:
# You renamed aws_instance.web → aws_instance.app
resource "aws_instance" "app" {
# ...unchanged...
}
moved {
from = aws_instance.web
to = aws_instance.app
}Run terraform plan and you'll see web has moved to app — zero changes to real infrastructure. The block is safe to leave in the codebase; you can delete it a release later once every state has been applied.
Moving a Resource Into a Module
The most common large refactor: you extract resources into a module. moved makes it a no-op:
moved {
from = aws_instance.app
to = module.compute.aws_instance.app
}Migrating count → for_each
This is the refactor that used to mean recreating everything (positional index → string key). With moved, you remap each instance:
# Was: resource "aws_subnet" "public" { count = 3 ... }
# Now: resource "aws_subnet" "public" { for_each = { ... } ... }
moved {
from = aws_subnet.public[0]
to = aws_subnet.public["us-east-1a"]
}
moved {
from = aws_subnet.public[1]
to = aws_subnet.public["us-east-1b"]
}
moved {
from = aws_subnet.public[2]
to = aws_subnet.public["us-east-1c"]
}moved blocks are the declarative replacement for terraform state mv. Prefer them — they're reviewed in the PR, applied as part of the normal workflow, and work across every environment automatically instead of requiring a manual command per state file.
removed: Drop From State Without Destroying
Sometimes you want Terraform to stop managing a resource but leave it running — handing it to another team, another state, or to manual ownership. The imperative tool was terraform state rm. The declarative one (Terraform 1.7+) is the removed block:
# Delete the resource block, then add:
removed {
from = aws_s3_bucket.legacy_logs
lifecycle {
destroy = false # forget it, but don't delete the bucket
}
}Set destroy = true instead and the removed block becomes a declarative terraform destroy for that one resource. Either way, delete the original resource block — the removed block stands in for it.
import: Bring Existing Resources Under Management
import blocks (Terraform 1.5+) make imports reviewable in a PR instead of a one-off CLI command — see State Management for the basics. The expert addition is config generation:
import {
to = aws_iam_role.ci
id = "ci-deploy-role"
}# Terraform writes a starter resource block for you
terraform plan -generate-config-out=generated.tfThis is a huge time-saver when adopting Terraform over a pile of click-ops resources: import the IDs, let Terraform draft the HCL, then clean it up. After the import has applied everywhere, delete the import block.
Cross-Stack References
Once you split infrastructure into multiple states (network, platform, app), one stack needs another's outputs. Two approaches:
terraform_remote_state
Read another state's outputs directly:
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "myorg-terraform-state"
key = "network/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
}Loosely-Coupled Lookups
terraform_remote_state couples the consumer to the producer's backend layout and exposes the entire output set. A looser alternative: the producer publishes values to a well-known location, the consumer reads them with a normal data source.
# Producer writes an output to SSM Parameter Store
resource "aws_ssm_parameter" "vpc_id" {
name = "/network/vpc_id"
type = "String"
value = aws_vpc.main.id
}
# Consumer reads it — no knowledge of the producer's state
data "aws_ssm_parameter" "vpc_id" {
name = "/network/vpc_id"
}terraform_remote_state | Published lookup (SSM/data source) | |
|---|---|---|
| Coupling | Consumer knows producer's backend + state key | Consumer knows only a parameter name |
| Exposure | All outputs readable | Only what's deliberately published |
| Cross-tool | Terraform-only | Anything can read it (scripts, other IaC) |
| Best for | Tightly-related stacks you own | Stable contracts across teams |
Migrating Backends
Moving state from local to S3, or between backends, is a first-class operation. Change the backend block, then:
terraform init -migrate-state # copies existing state into the new backendTerraform detects the change and offers to copy state. Use -reconfigure instead when you want to ignore existing state and start fresh against the new backend (e.g., the old one is gone).
Replacing a Provider
When a provider's registry address changes — legacy -/aws style addresses, or migrating between Terraform and OpenTofu — rewrite the provider reference recorded in state:
terraform state replace-provider \
registry.terraform.io/-/aws \
registry.terraform.io/hashicorp/awsThis rewrites state only; it makes no cloud API calls.
The Dependency Lock File
.terraform.lock.hcl is generated by terraform init and pins exact provider versions and their checksums. It's the IaC equivalent of package-lock.json.
# .terraform.lock.hcl (excerpt)
provider "registry.terraform.io/hashicorp/aws" {
version = "5.31.0"
constraints = "~> 5.0"
hashes = [
"h1:...",
"zh:...",
]
}Commit .terraform.lock.hcl to git. It guarantees every developer and CI run uses the identical provider build. Without it, a colleague who runs init a week later silently gets a newer provider — and a "no-op" PR starts showing changes.
A common CI failure: the lock file only has hashes for the platform that generated it (say darwin_arm64), then Linux CI rejects it. Record hashes for every platform you run on:
terraform providers lock \
-platform=linux_amd64 \
-platform=darwin_arm64 \
-platform=windows_amd64Splitting a Monolithic State
A state file that's grown to hundreds of resources is slow to plan and a single blast radius. To carve out a subsystem, the modern approach combines removed (in the source) with import (in the destination), so both sides are declarative and reviewable:
- In the new config, add
importblocks for the resources you're extracting (ormovedif it's a new module in the same state). - In the old config, replace those resources with
removed { ... destroy = false }. - Apply the new config (imports them), then the old config (forgets them).
terraform planon both should be clean.
The imperative equivalent — terraform state mv -state-out=other.tfstate — still works for quick local splits, but the declarative path leaves an audit trail.
What's Next
You can now reshape Terraform without fear. Next, the language and runtime features that separate competent configs from bulletproof ones → Expert Patterns.