Steven's Knowledge

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.tf

This 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_statePublished lookup (SSM/data source)
CouplingConsumer knows producer's backend + state keyConsumer knows only a parameter name
ExposureAll outputs readableOnly what's deliberately published
Cross-toolTerraform-onlyAnything can read it (scripts, other IaC)
Best forTightly-related stacks you ownStable 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 backend

Terraform 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/aws

This 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_amd64

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

  1. In the new config, add import blocks for the resources you're extracting (or moved if it's a new module in the same state).
  2. In the old config, replace those resources with removed { ... destroy = false }.
  3. Apply the new config (imports them), then the old config (forgets them).
  4. terraform plan on 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.

On this page