IaC Multi-Cloud

Refactoring Terraform Safely with moved, import, and removed Blocks

The single most dangerous word in a Terraform plan is destroy. You rename a resource for clarity, wrap a few resources in a module to DRY up three environments, and suddenly terraform plan proposes to tear down a database and replace it. The infrastructure is identical; only the address in state changed. Terraform sees an orphan and a stranger and reconciles them the only way it knows how.

Historically the fix was terraform state mv, terraform import, and terraform state rm run by hand against live state. Those imperative commands are unreviewable, un-replayable, and per-workspace. If you run them once in dev and forget to run them in prod, the divergence surfaces as a destroy in the worst possible place. Since Terraform 1.1 (moved), 1.5 (config-driven import), and 1.7 (removed), all three operations are expressible as configuration: committed to git, reviewed in a PR, applied identically across every workspace, and self-deleting once the refactor lands. This is the playbook I use for restructuring state on running production systems.

1. Why renames and module moves trigger destroy/create

Terraform tracks every managed object by its resource address in state: aws_s3_bucket.logs, or module.network.aws_subnet.private["a"]. The address is the identity. The cloud-side ID (the actual bucket name, the subnet subnet-0abc) lives inside that state entry but is not how Terraform matches config to reality during planning.

When you change the address — rename the resource, move it into a module, add for_each so the key changes — Terraform diffs addresses, not cloud IDs. The old address has config but no state match (it reads as “needs to be created from scratch… but wait, it’s gone”), and the old state entry has no config match (it reads as “managed object no longer in config: destroy it”). The result is the dreaded pair:

  # aws_s3_bucket.application_logs will be created
  + resource "aws_s3_bucket" "application_logs" { ... }

  # aws_s3_bucket.logs will be destroyed
  - resource "aws_s3_bucket" "logs" { ... }

Same bucket. Different label. The job of moved, import, and removed is to teach Terraform that an address changed identity without the underlying object changing, so the plan collapses to a no-op (or a benign in-place update).

Rule of thumb: if a plan shows a paired create + destroy of the same kind of resource and you only edited names or module structure, you have a refactor problem, not an infrastructure problem. Stop. Do not apply. Reach for a moved block.

2. Renaming and re-homing resources with moved blocks

A moved block is a directive that says “the object previously at address A is now at address B; carry its state forward.” It runs during plan, before Terraform computes any create/destroy, so the diff never materializes.

The simplest case is a rename:

# Old: resource "aws_s3_bucket" "logs"
# New name for clarity:
resource "aws_s3_bucket" "application_logs" {
  bucket = "acme-app-logs-prod"
}

moved {
  from = aws_s3_bucket.logs
  to   = aws_s3_bucket.application_logs
}

Moving resources into a module is the high-value case. Say you have three flat copies of a VPC and you finally extract a network module:

module "network" {
  source = "./modules/network"
  cidr   = "10.20.0.0/16"
}

moved {
  from = aws_vpc.main
  to   = module.network.aws_vpc.this
}

moved {
  from = aws_subnet.private["a"]
  to   = module.network.aws_subnet.private["a"]
}

moved blocks are declarative and idempotent. Once the move has been applied in a workspace, the block is a no-op there forever; running it again does nothing. That is exactly why it is safe to commit one block and let it ripple across dev, staging, and prod on their normal apply cadence — each workspace processes the move the first time it plans and ignores it thereafter. Chaining is allowed (A -> B, then later B -> C), and Terraform resolves the chain, so you can leave history in place across several refactors.

A few hard constraints worth internalizing:

After the refactor has shipped everywhere, the blocks are dead weight. You can delete them in a later release — removing a satisfied moved block produces no plan change.

3. Config-driven import with -generate-config-out

moved handles objects Terraform already manages. Import is for objects that exist in the cloud but not in state — a resource someone created in the console, or one you are adopting from another tool. Before 1.5 this meant terraform import ADDR ID (imperative, one resource at a time, and you still had to hand-write the HCL). The import block makes it declarative and plannable, and -generate-config-out writes the HCL for you.

Add an import block pointing at the target address and the cloud ID:

import {
  to = aws_iam_role.ci_deployer
  id = "ci-deployer"
}

Now generate configuration. The flag must point at a file that does not yet exist — Terraform refuses to overwrite:

terraform plan -generate-config-out=generated.tf

Terraform reads the live resource, emits matching HCL into generated.tf, and shows you a plan that imports rather than creates:

  # aws_iam_role.ci_deployer will be imported
  resource "aws_iam_role" "ci_deployer" {
      name = "ci-deployer"
      ...
  }

Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.

Generated config is a starting point, not a finished artifact — HashiCorp documents this explicitly, and the format may change between minor versions. Treat it as a first draft:

Move the cleaned-up resource out of generated.tf into its proper file, then run terraform apply. Applying an import block writes the object into state and reconciles it against config. Leave the import block in place until you have applied in every workspace, then delete it. Like moved, a satisfied import block is a no-op — Terraform notices the object is already in state and skips it — so a stale block is harmless but clutters the config.

4. Bulk-importing with for_each and import blocks

Importing twenty manually-created log groups one block at a time is tedious. import blocks accept for_each, so you can drive imports from a map and import a whole fleet in one apply.

locals {
  legacy_log_groups = {
    "api"     = "/acme/api"
    "worker"  = "/acme/worker"
    "scheduler" = "/acme/scheduler"
  }
}

resource "aws_cloudwatch_log_group" "this" {
  for_each          = local.legacy_log_groups
  name              = each.value
  retention_in_days = 30
}

import {
  for_each = local.legacy_log_groups
  to       = aws_cloudwatch_log_group.this[each.key]
  id       = each.value
}

The to argument uses the instance key so each map entry maps to one resource instance; id is the import identifier for that specific object.

The critical limitation: -generate-config-out does not work with for_each (or count) on import blocks or on the target resource. Terraform errors with “the given import block is not compatible with config generation.” The practical workflow is therefore two-phase:

  1. Write the resource HCL by hand (or generate it for a single representative resource without for_each, then refactor to for_each).
  2. Add the for_each import block and run terraform plan to confirm the import map is correct before applying.

Always review the plan output line by line — confirm it shows N to import, 0 to destroy. A typo in an id produces an import failure (loud, safe); a typo in a to key can leave a real resource unmanaged while a planned resource gets created (quiet, dangerous).

5. Decommissioning from state with removed blocks

The mirror of import is removed: take an object out of Terraform’s management. The old way was terraform state rm ADDR — imperative, unreviewable, and it always orphaned the object whether you meant to or not. The removed block (Terraform 1.7+) makes this a reviewed config change with an explicit choice about the object’s fate.

Delete the resource block from your configuration, then add a removed block for the same address:

removed {
  from = aws_s3_bucket.legacy_artifacts

  lifecycle {
    destroy = false
  }
}

The nested lifecycle block is required and the destroy argument is the entire point of the feature:

destroy Effect
false Forget the object: drop it from state, leave the real infrastructure running. The state-rm equivalent — for handing a resource to another team or tool.
true Remove from state and destroy the real infrastructure on apply.

The from address must not include instance keys or indices — you reference the resource as a whole (aws_s3_bucket.legacy_artifacts), not aws_s3_bucket.legacy_artifacts["x"]. A removed block can also reference an entire module, which removes every resource that module managed:

removed {
  from = module.legacy_cache

  lifecycle {
    destroy = false
  }
}

If you need cleanup to run on the way out — deregistering from an external system, for example — removed blocks support destroy-time provisioners only. Every provisioner must set when = destroy:

removed {
  from = aws_instance.bastion

  lifecycle {
    destroy = true
  }

  provisioner "local-exec" {
    when    = destroy
    command = "echo 'decommissioned ${self.id}' >> /var/log/teardown.log"
  }
}

As with moved and import, once every workspace has processed the removal you delete the removed block. Until then it is idempotent — a workspace that has already forgotten the object treats the block as a no-op.

6. Refactoring across module boundaries and provider aliases

Real refactors cross boundaries. Two cases deserve specific handling.

Promoting resources between parent and child modules. When you pull a resource out of a child module up into the root (or push one down), the moved block lives in the configuration that contains the destination address and references the full source path:

# Pulling a security group out of the network module up to root
moved {
  from = module.network.aws_security_group.shared
  to   = aws_security_group.shared
}

Because moved operates on addresses within one state, both endpoints must resolve in the same root configuration. If the resource is genuinely leaving this state for another root module, that is not a moved — you removed { destroy = false } it here and import it there (Step 7).

Provider aliases. A resource’s state entry records which provider configuration manages it. If you are re-homing a resource that targets an aliased provider (a different region or account), the destination resource must carry the matching provider argument, and the moved block handles the address change as usual:

provider "aws" {
  alias  = "eu"
  region = "eu-west-1"
}

resource "aws_kms_key" "eu_data" {
  provider = aws.eu
}

moved {
  from = aws_kms_key.data_eu
  to   = aws_kms_key.eu_data
}

moved does not change the provider association — it carries the existing state forward. If you are actually migrating an object to a different provider/account, that is a destroy-and-recreate or a cross-state removed/import, not a rename.

7. Validating with plan and CI guardrails

Every block above is validated the same way: read the plan and trust nothing else. The non-negotiable check before any apply is that the plan for a pure refactor shows zero destroys:

terraform plan -out=refactor.tfplan
terraform show -json refactor.tfplan > plan.json

Then assert on the machine-readable plan instead of eyeballing colored text. This jq filter prints every address Terraform intends to delete:

jq -r '
  .resource_changes[]
  | select(.change.actions | index("delete"))
  | .address
' plan.json

For a clean state refactor that list must be empty. Wire that into CI as a hard gate so an accidental destroy can never reach apply:

# .github/workflows/terraform.yml (excerpt)
- name: Terraform Plan
  run: |
    terraform plan -out=tf.plan
    terraform show -json tf.plan > tf.plan.json

- name: Block accidental deletes
  run: |
    DELETES=$(jq -r '
      [ .resource_changes[]
        | select(.change.actions | index("delete"))
        | .address ] | length
    ' tf.plan.json)
    echo "Resources marked for deletion: $DELETES"
    if [ "$DELETES" -ne 0 ]; then
      echo "::error::Plan deletes $DELETES resource(s). Refactor PRs must be zero-destroy."
      jq -r '.resource_changes[] | select(.change.actions | index("delete")) | .address' tf.plan.json
      exit 1
    fi

When a genuine teardown is intended (removed { destroy = true }), gate it behind an explicit label or variable so the destroy is a deliberate, reviewed exception rather than the default-allowed case. A belt-and-braces complement is prevent_destroy on irreplaceable resources, which causes the plan itself to error if anything proposes their deletion:

resource "aws_db_instance" "primary" {
  # ...
  lifecycle {
    prevent_destroy = true
  }
}

Treat prevent_destroy as a safety interlock, not a workflow — it stops the plan hard, which is the point.

Verify

Walk this checklist on a real refactor before you apply to production:

  1. Lock and target a single workspace first. Run the refactor against dev end-to-end before touching prod. Because the blocks are committed config, the higher environments will replay the exact same moves.
  2. Confirm zero destroys on a pure refactor. Run the jq delete filter from Step 7 against the JSON plan. Empty output is the pass condition.
  3. Confirm the import count matches your intent. For import blocks, the plan summary should read N to import with 0 to add for the resources you are adopting — 0 to add proves Terraform matched existing objects rather than planning new ones.
  4. Apply, then re-plan to a clean no-op. The post-apply plan should be empty (or show only intentional in-place updates). A lingering create/destroy means a moved/import address did not match.
  5. Verify state, not just plan. Run terraform state list and confirm the new addresses exist and the old ones are gone. Spot-check one object with terraform state show ADDR to confirm the cloud ID carried over intact.
  6. Garbage-collect the blocks. After every workspace has applied, delete the satisfied moved / import / removed blocks in a follow-up PR and confirm that PR also plans to zero changes.

Rollback and recovering from a botched refactor

State refactors are reversible if — and only if — you have state backups. Before any state-mutating apply, snapshot state:

terraform state pull > backup-$(date +%Y%m%d-%H%M%S).tfstate

If you use an S3 backend, enable bucket versioning and DynamoDB locking so every state write is recoverable and concurrent applies are impossible. With a backup in hand, recovery paths are:

The meta-rule: never run a state-mutating apply you cannot undo. Backup first, prove zero-destroy in CI, apply to the lowest environment, verify, then promote. The blocks are designed so that the same committed change produces the same safe outcome in every workspace — which is the entire reason to prefer them over imperative state mv / import / state rm.

Checklist

terraformrefactoringimport-blocksstatemoved-blocks

Comments

Keep Reading