not accepting clients
← back to blog

terraform state without surgery

terraform's moved, import, and removed blocks turn state surgery into configuration changes that get a plan, a review, and a history like any other.

contents

a module gets renamed. a resource moves from a root module into a shared one. a hand-created load balancer needs to come under management. an old resource has to leave terraform’s control without being destroyed. all four are routine, and all four used to be done with terraform state mv, terraform import, and terraform state rm - imperative commands run by one person against a shared backend.

those commands have no plan and no review. they run against whatever state the backend holds at that moment, they are invisible in the diff a colleague reviews, and a mistyped address is a resource that terraform now believes does not exist and will happily create a second time. moved, import, and removed blocks turn all three into configuration.

the short version

three block types, all declarative, all visible in terraform plan:

  • moved: this resource used to have that address. update the state binding, leave the infrastructure alone.
  • import: this existing object should be bound to this address. read it into state, then plan against it.
  • removed: stop managing this resource, and destroy it unless told not to.

each one is written into the configuration, planned, reviewed as part of a pull request, and applied by the normal pipeline. the state file is never edited by hand.

moved

renaming a resource without a moved block produces the worst possible plan: destroy the old address, create the new one. for a database or a load balancer that is an outage produced entirely by a refactor.

resource "aws_s3_bucket" "artifacts" {
  bucket = "kontrolplane-artifacts"
}

renaming artifacts to build_artifacts needs the old address recorded:

moved {
  from = aws_s3_bucket.artifacts
  to   = aws_s3_bucket.build_artifacts
}

resource "aws_s3_bucket" "build_artifacts" {
  bucket = "kontrolplane-artifacts"
}

the plan now reports the move and nothing else:

Terraform will perform the following actions:

  # aws_s3_bucket.artifacts has moved to aws_s3_bucket.build_artifacts
    resource "aws_s3_bucket" "build_artifacts" {
        id = "kontrolplane-artifacts"
    }

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

the same block moves a resource into or out of a module, which is the more common case:

moved {
  from = aws_iam_role.deployer
  to   = module.iam.aws_iam_role.deployer
}

and it handles the count-to-for_each conversion that otherwise recreates everything, because the address changes from an index to a key:

moved {
  from = aws_subnet.private[0]
  to   = aws_subnet.private["eu-west-1a"]
}

moved {
  from = aws_subnet.private[1]
  to   = aws_subnet.private["eu-west-1b"]
}

one block per element, which is verbose and correct. the mapping from index to key is a decision only the author can make, and getting it wrong silently rebinds the wrong subnet.

moved blocks are kept in the configuration after they apply. once every state that could contain the old address has been migrated they can be deleted, but a block that no longer matches anything is a no-op, not an error, so leaving them is cheap. the exception is a published module, where removing a moved block breaks consumers who have not upgraded yet.

import

import blocks turn adoption into a reviewable plan. the block names the target address and the provider-specific id:

import {
  to = aws_s3_bucket.legacy_assets
  id = "kontrolplane-legacy-assets"
}

resource "aws_s3_bucket" "legacy_assets" {
  bucket = "kontrolplane-legacy-assets"
}

the plan reads the real object and diffs the configuration against it, which surfaces every attribute that does not match before anything is written:

  # aws_s3_bucket.legacy_assets will be imported
  # (config refers to values not yet known)
    resource "aws_s3_bucket" "legacy_assets" {
        bucket = "kontrolplane-legacy-assets"
    }

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

for a resource with more than a handful of attributes, writing the configuration by hand and iterating on the plan is tedious. -generate-config-out writes a starting point:

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

the generated hcl is a literal transcription of the object’s current state, including read-only attributes and defaults. it needs editing before it is worth committing - variables substituted for hardcoded values, computed attributes dropped - but it beats reading provider documentation attribute by attribute.

importing many objects at once works through for_each on the import block:

locals {
  zones = {
    "kontrolplane.dev" = "Z0123456789ABCDEFGHIJ"
    "kontrolplane.io"  = "Z0987654321JIHGFEDCBA"
  }
}

import {
  for_each = local.zones
  to       = aws_route53_zone.managed[each.key]
  id       = each.value
}

resource "aws_route53_zone" "managed" {
  for_each = local.zones
  name     = each.key
}

unlike moved, import blocks should be deleted once applied. they are a one-time instruction, and leaving them means every future plan re-reads objects that are already in state.

removed

taking a resource out of terraform’s management without destroying it is the operation that used to be terraform state rm, and it is the one where a mistake is least recoverable - the state entry is gone, and the resource is now unmanaged and invisible.

the declarative form requires deleting the resource block and adding a removed block naming the same address:

removed {
  from = aws_s3_bucket.build_artifacts

  lifecycle {
    destroy = false
  }
}

destroy = false is the safe direction: forget the binding, leave the object alone. it has to be written out: by default a removed block destroys the real resource as well as the state entry.

this is the mechanism behind handing a resource to another tool. moving a workload from a terraform-managed helm release to a declarative controller means removing it from terraform state while it keeps running, then letting the controller adopt it. the same block works on whole modules:

removed {
  from = module.legacy_monitoring

  lifecycle {
    destroy = false
  }
}

setting destroy = true makes it an ordinary destroy expressed as a removal, which is occasionally what is wanted when a module is being deleted outright.

checking before applying

state operations deserve a look at the actual state, not just the plan. list what terraform believes it manages:

terraform state list

read one resource’s recorded attributes to confirm an address before writing a moved block against it:

terraform state show aws_s3_bucket.build_artifacts

and, when a plan proposes something unexpected, save it and read it as json rather than trusting the summary line:

terraform plan -out=tfplan && terraform show -json tfplan | jq '.resource_changes[] | select(.change.actions != ["no-op"]) | {address, actions: .change.actions}'

the pattern that makes this reviewable is committing the block, letting ci produce the plan, and reading the plan in the pull request before merge. the apply then does exactly what the reviewed plan described.

what to watch out for

moved does not validate the target exists in the real world. it rebinds state entries by address. moving to an address whose configuration describes a different object leaves terraform managing the wrong thing, and the next plan proposes changes that reshape a live resource to match an unrelated configuration.

import matches on id, not on identity. provider ids are not always the obvious value. security groups import by id, iam roles by name, and some resources require a compound id joined by a separator that only the provider documentation records. an import against the wrong id either fails or binds something unintended.

generated configuration is not review-ready. -generate-config-out emits every attribute the provider returns, including deprecated ones and computed defaults that will drift. committing it unedited produces a permanent diff.

removed is not undo. once applied, the state entry is gone. re-adopting the resource means writing an import block and reconstructing the configuration.

state operations still need a lock. these blocks are safer than the imperative commands but they run against the same backend. an s3 backend without a lock, or a pipeline that runs two applies concurrently, corrupts state regardless of how the change was expressed.

back up the state before a large migration. terraform state pull > backup.tfstate costs nothing and is the only way back from a mass rebinding that went wrong. keep it until the following apply is confirmed clean.

references

[1] terraform documentation. “moved block.”
developer.hashicorp.com/terraform/language/moved

[2] terraform documentation. “import block.”
developer.hashicorp.com/terraform/language/import

[3] terraform documentation. “generating configuration.”
developer.hashicorp.com/terraform/language/import/generating-configuration

[4] terraform documentation. “removed block.”
developer.hashicorp.com/terraform/language/resources/syntax#removing-resources

[5] terraform documentation. “state locking.”
developer.hashicorp.com/terraform/language/state/locking

[6] terraform documentation. “command: state.”
developer.hashicorp.com/terraform/cli/commands/state

# ask the author

a question
about this
post?

direct line