Stop Using terraform state mv: The moved, import, and removed Blocks

Published: | Category: Terraform
Quick Summary: Terraform has three blocks that change state without touching real infrastructure. moved renames an object, import adopts something that already exists, and removed lets go of something without destroying it. Between them they replace terraform state mv, terraform import, and terraform state rm - and unlike those commands they live in your repository, show up in terraform plan, and go through code review like everything else. This post covers all three, the pattern for splitting one state file into two, and the mistakes that cost people production resources.

The Problem With State Commands

Every Terraform user eventually renames something. You call a resource aws_instance.web, six months later it should be aws_instance.api, and you change the label. Then terraform plan tells you it is going to destroy a running server and build a new one.

Terraform is not being difficult. The resource address is the identity. Rename the label and, as far as Terraform can tell, one resource disappeared from your configuration and a different one appeared. Destroy and create is the honest reading of what you wrote.

The old fix was a CLI command:

terraform state mv aws_instance.web aws_instance.api

It works. It is also a terrible thing to depend on, for reasons that have nothing to do with the syntax:

  • It happens on somebody's laptop. Nothing in the repository records that it happened, or why.
  • It skips code review. The most dangerous operation in your workflow is the one nobody else sees.
  • It is invisible to the plan. You do not get to preview it, and CI cannot check it.
  • It is not repeatable. Ten workspaces means running it ten times, correctly, from memory.
  • It fights remote state. Someone else applying while you surgically edit state is a bad afternoon.

The three blocks below fix all five problems at once, and that - not the syntax - is the actual point.

1. moved: Rename Without Destroying

Available since Terraform 1.1. Two required arguments:

moved {
  from = aws_instance.web
  to   = aws_instance.api
}

At plan time Terraform looks for an object at the from address, and if it finds one, treats it as the object now living at to. Your plan changes from "1 to destroy, 1 to add" to zero changes. Nothing happens to the running instance.

It handles more than plain renames. All of these are address changes, so all of them are moved territory:

# adding count or for_each to an existing single resource
moved {
  from = aws_instance.web
  to   = aws_instance.web[0]
}

# pulling a resource into a module
moved {
  from = aws_security_group.db
  to   = module.database.aws_security_group.db
}

# renaming a whole module
moved {
  from = module.vpc_old
  to   = module.network
}

# switching from count to for_each
moved {
  from = aws_subnet.private[0]
  to   = aws_subnet.private["ap-south-1a"]
}

That fourth one is worth knowing about before you need it. Migrating from count to for_each is one of the most common refactors in real Terraform, and without moved blocks it is a full rebuild of every resource in the set - because [0] and ["ap-south-1a"] are simply different addresses.

What moved will not do

It cannot change a resource's type. from = aws_instance.a to to = aws_db_instance.a is not an address change, it is a different kind of object. Some providers can support moving state across resource types, but this is opt-in on the provider side and not something to assume. When you cannot do it, the fallback is the removed plus import pattern further down.

It cannot cross state files. moved reassigns addresses inside one state. Moving a resource from one root module to another is a different job.

You cannot delete the block the moment it works. This is the part people get wrong. The block is an instruction that has to be present when a given state gets applied. If two teams share the module and one has not applied yet, deleting the block early means their old object no longer matches anything in configuration - and Terraform's reading of that is "destroy it". Keep the block until every state that could still hold the old address has applied, then clean it up.

2. import: Adopt What Already Exists

Available since Terraform 1.5. This is for infrastructure that exists in the cloud but not in your state - created by hand during an incident, built by a different team, or inherited from before anyone wrote Terraform.

import {
  to = aws_s3_bucket.logs
  id = "my-company-logs-bucket"
}

resource "aws_s3_bucket" "logs" {
  bucket = "my-company-logs-bucket"
  # ... the rest of the configuration
}

You need both halves: the import block that says "adopt this", and the resource block that describes what it should look like. Run terraform plan and Terraform tells you it plans to import the object, plus any drift between the real resource and the configuration you wrote for it.

That preview is the whole improvement over the old terraform import command. The CLI version wrote to state immediately and then left you to reverse-engineer matching configuration, usually by reading the state file. The block version is a plannable, reviewable, reversible change - and you can throw the branch away if the plan looks wrong.

Generating the configuration for you

Writing configuration by hand for a resource with forty attributes is miserable. Terraform will do the first draft:

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

For any resource named in an import block that has no configuration yet, Terraform writes HCL for it into that file. Treat the output as a draft, not an answer - it is verbose, it includes attributes you do not want to manage, and it needs a human pass before it belongs in your repository. It still beats starting from an empty file.

Importing many resources at once

for_each works in import blocks, which turns a fifty-resource adoption into one block:

import {
  for_each = {
    logs    = "my-company-logs-bucket"
    backups = "my-company-backups-bucket"
    static  = "my-company-static-bucket"
  }
  to = aws_s3_bucket.this[each.key]
  id = each.value
}

The one constraint that matters

The id must be known at plan time. You cannot derive it from another resource's attribute, because that value does not exist until apply. In practice this means the ID comes from a literal, a variable, or a local - never from something Terraform is about to create. If you find yourself wanting to import a resource whose ID depends on an apply, you want a data source or a dependency, not an import.

3. removed: Let Go Without Destroying

Available since Terraform 1.7, and the block most worth understanding, because the failure mode is destroying production.

Start with the default behaviour, because it surprises people. Deleting a resource block from your configuration is an instruction to destroy the resource. Not to forget it - to destroy it. Terraform sees a managed object with no configuration and concludes you want it gone.

So when you want Terraform to stop managing something that should keep running - handing a database to another team, migrating it to a different tool, splitting a state file - deleting the block is exactly the wrong move. You need removed:

# the resource block is deleted from the configuration,
# and this takes its place

removed {
  from = aws_db_instance.legacy

  lifecycle {
    destroy = false
  }
}

The lifecycle block is required, and its destroy argument is the entire decision:

  • destroy = false - drop the object from state and leave the real infrastructure alone. Terraform stops managing it and stops knowing about it. This is the "hand it off" case.
  • destroy = true - drop the object from state and destroy the real resource. This is the same outcome as deleting the resource block, written explicitly.

Read that pair twice before you use it in anger. A single boolean is the difference between "another team now owns this database" and "this database no longer exists". If you are ever unsure which one you typed, run the plan and read it - Terraform will tell you plainly which of the two it intends to do.

removed works on modules too, which is how you retire a whole module without tearing down what it built:

removed {
  from = module.legacy_monitoring

  lifecycle {
    destroy = false
  }
}

As with moved, the block has to stay in place until every relevant state has applied it. Once it has, you can delete it.

The Combo: Splitting One State Into Two

This is where the "opposite" framing earns its keep. moved cannot move a resource between state files - but removed and import together can, because one takes an object out of state without destroying it and the other adopts an existing object into state without creating it. Chain them and the resource never stops running.

Say your monolithic root module has grown to four hundred resources and you want the database layer in its own state. For each resource moving out:

  1. Record the real IDs first. terraform state show aws_db_instance.main and write down the identifier the provider uses for imports. Do this before you change anything - once the object leaves state, this information is gone from Terraform and you are digging through the AWS console.
  2. In the old configuration: delete the resource block, add a removed block with destroy = false. Plan, read the plan carefully, apply. The resource is now unmanaged and still running.
  3. In the new configuration: write the resource block, add an import block with the ID from step 1. Plan. You should see an import and, ideally, no other changes.
  4. Reconcile the drift. If the plan wants to modify the resource, your new configuration does not match reality yet. Fix the configuration until the plan is clean. Do not apply a plan that wants to change a production database because you are in a hurry to finish a refactor.
  5. Apply, then clean up both blocks once every state has been applied.
The failure mode to plan for: between step 2 and step 5 the resource is managed by nothing. Nobody's plan will show drift on it, and nobody's apply protects it. Keep that window short, do it deliberately, and do not start it on a Friday evening. Write down the IDs before you begin - that note is the only thing standing between you and reconstructing a resource's identity by hand.

Which Block Do I Want?

The whole decision, in four lines:

  • The resource stays managed, its address changes - moved
  • The resource exists in the cloud, not in state - import
  • The resource should keep running but stop being managed - removed with destroy = false
  • The resource should move to a different state file - removed then import

And the case that needs no block at all: the resource should genuinely be destroyed. Delete the resource block and let Terraform do what it was always going to do.

Gotchas Worth Reading Before You Need Them

These blocks are configuration, so they propagate. A moved block in a shared module ships to every caller of that module. That is usually what you want - the rename becomes safe for everyone - but it also means the block cannot be deleted on your schedule. It has to survive until the slowest consumer has applied.

A missing lifecycle block in a removed block is a configuration error, not a default. Terraform makes you state your intent. This is deliberate and good.

terraform plan is the review step, not a formality. Every one of these operations is previewable. All three of the CLI commands they replace were not. If you take one habit from this post, take reading the plan on refactors as carefully as you would on a change to a production resource - because that is what it is.

Back up state before state surgery. Cheap, boring, and the only thing that helps when something goes genuinely wrong. If you are on S3 with versioning, confirm versioning is actually on rather than assuming.

Check what your Terraform version supports. moved needs 1.1, import needs 1.5, removed needs 1.7. Stable is 1.15.8 at the time of writing, so most teams have all three - but pinned CI images have a way of being years behind the laptop you tested on.

One forward-looking note: import blocks inside non-root modules, and a resource-level lifecycle { destroy = false }, have been moving through the pre-release line rather than a stable release. If either would change how you structure a migration, check the release notes for the version you actually run before designing around them.

Frequently Asked Questions

What is the opposite of the moved block?

Two blocks, depending on the direction. removed takes an object out of state without destroying it, where moved keeps it and changes its address. import brings existing infrastructure into state without creating it. Together: import adds, removed takes away, moved renames - the three state changes that leave real infrastructure untouched.

How do I remove a resource from state without destroying it?

Delete the resource block and add a removed block with lifecycle { destroy = false }. This is the declarative replacement for terraform state rm, and unlike that command it appears in the plan.

What happens if I just delete a resource block?

Terraform destroys the resource. Deleting configuration means destroy, not forget. Use removed with destroy = false when you want it to keep running.

Can a moved block change a resource's type?

Not by itself - it reassigns addresses. Unless the provider explicitly supports cross-type state moves, use removed with destroy = false in the old location and import in the new one.

Do I have to delete these blocks after the refactor?

Eventually, and not before every state that might still hold the old address has applied. Deleting a moved block too early on a state that never applied it makes Terraform treat the old object as unmanaged.

Can I still use terraform state mv?

It still exists and still works. But it runs from a laptop, bypasses review, never appears in a plan, and cannot be repeated reliably across workspaces. Reach for the blocks and keep the commands for emergencies.

My Takeaway

The interesting thing about these three blocks is not the syntax - it is that Terraform moved its most dangerous operations out of the CLI and into the repository. State surgery used to be a thing a person did, once, without a record. Now it is a diff someone reviews, a plan someone reads, and a change that runs identically in every workspace.

The lesson generalises past Terraform: any operation important enough to be dangerous is important enough to be in version control. If your recovery from a mistake depends on remembering what you typed, the tool is not the problem - the workflow is.

For the official reference, see the HashiCorp docs on refactoring Terraform state and the individual reference pages for the moved, import, and removed blocks.