Refactoring with moved and removed Blocks

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Code is rewritten far more often than infrastructure is rebuilt. A resource gets a better name, a cache that began life in the root configuration becomes a reusable module, a single container turns into a set of replicas, a database is handed over to another team. In the code each of these is a small edit. For Terraform and OpenTofu each one is also a change of identity, because the tool recognises every object by its address in the configuration. Edit the address and the plan, left to itself, concludes that the old object is no longer wanted and that a new one is needed: destroy, then create. For a stateless container that costs a restart. For the Bookshelf PostgreSQL volume it costs the data.

This page describes how to refactor safely, as documented for Terraform 1.17 (released 2026-10-21) and OpenTofu 1.13 (latest patch 1.13.2). It separates internal refactoring (the infrastructure stays the same, only its addresses change) from external refactoring (the contract with other configurations and other teams changes). It then covers the two configuration-driven tools, the moved block and the removed block, in detail; the legacy terraform state mv and terraform state rm commands; handing objects between configurations with removed and import; the deprecated attribute that retires variables and outputs in two steps; and the rules for breaking changes and major versions of a module.

It follows State, where the bindings between addresses and real objects are explained, and Modules and Module Development and Publishing, whose versioning rules this page builds on. It sits next to Import and Query, which covers the third configuration-driven block. All names and identifiers are placeholders from the Bookshelf scenario (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers bucket). No Terraform or OpenTofu binary was available while writing, so every plan excerpt is abbreviated and marked as illustrative, except the few lines quoted from the official tutorial.

Why a rename is not free

Terraform builds its plan by comparing two sets of objects, both keyed by address: the resource instances declared in the configuration, and the resource instances recorded in state. An address such as docker_container.api or module.cache.docker_container.this["reviews"] is the only link between a declaration and a real object. The state page calls this the binding between remote objects and resource instances.

By default, the official refactoring documentation says, Terraform interprets a change of address as an instruction to destroy the existing resource and create a new one at the new address. Nothing in the configuration says that the old and new blocks describe the same thing. Consider this edit, which only renames a block:

-resource "docker_container" "api" {
+resource "docker_container" "catalog_api" {
   name  = "bookshelf-catalog-api"
   image = docker_image.catalog_api.image_id
 }

An abbreviated plan for it, illustrative of what the tool reports, has two actions:

$ terraform plan

  # docker_container.api will be destroyed
  # (because docker_container.api is not in configuration)
  - resource "docker_container" "api" { ... }

  # docker_container.catalog_api will be created
  + resource "docker_container" "catalog_api" { ... }

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

The tutorial in the official documentation reports the same effect for a real refactoring: the plan after the configuration change and before adding moved blocks reads "Plan: 4 to add, 0 to change, 4 to destroy", and after adding them it reads "Plan: 0 to add, 0 to change, 0 to destroy" once the module rename is recorded.

The picture below compares the two outcomes for the rename above. The only difference between the lanes is whether the configuration contains a moved block.

flowchart TB A["Before: resource docker_container.api bound to a running container"] --> B["Edit: rename the block to docker_container.catalog_api"] B --> C{"moved block from api to catalog_api in the configuration?"} C -->|"yes"| D["Plan reads state and finds docker_container.api"] D --> E["State entry is renamed to docker_container.catalog_api"] E --> F["Configuration and state now match"] F --> G["Plan: 0 to add, 0 to change, 0 to destroy. The container keeps running."] C -->|"no"| H["State still holds docker_container.api, which is no longer declared"] H --> I["Plan: destroy docker_container.api"] H --> J["Plan: create docker_container.catalog_api"] I --> K["Plan: 1 to add, 0 to change, 1 to destroy. Downtime, and data loss for stateful objects."] J --> K

The rest of the page is about getting the left lane every time it is needed, and knowing when the right lane is in fact what you want.

Internal and external refactoring

The word refactoring covers two different jobs. The distinction comes from the practice of software engineering and is the useful way to choose a tool.

Internal refactoring External refactoring

What changes

The shape of the code: names, files, module boundaries, count or for_each keys. The real infrastructure is meant to stay exactly as it is.

The contract with outsiders: a module’s inputs and outputs, a module’s version, a provider’s major version, which state or workspace owns an object, who is responsible for it.

The goal

A plan with zero changes after the edit.

A change that other configurations can absorb on their own schedule, with a clear upgrade path.

Typical Bookshelf examples

Rename docker_container.api to catalog_api. Move the Redis container into module.cache. Turn the single reviews-api container into a for_each set of replicas. Split a platform module into network and data.

Rename the db_pass variable of the postgres module. Release postgres 2.0 with a different set of outputs. Hand the covers bucket from the platform configuration to a storage team’s configuration.

Main tools

moved blocks.

deprecated, semantic versioning, moved blocks shipped inside the module, removed plus import, migration notes.

Who is affected

Whoever runs the configuration, once, on the next plan.

Every consumer of the module, each at a different time and from a different version.

The two overlap. A module author does internal refactoring inside the module, and whether the result is invisible or a breaking change to the module’s consumers depends on whether the author recorded the moves. Internal refactoring is therefore the technical foundation of external refactoring, and the second half of this page applies it to module versions.

The official documentation also gives the reasons to refactor state itself, as opposed to code: plans that take too long because one configuration has grown too large, resources with different rates of change, changed ownership between teams, and a subsection that deserves to be a module. It advises grouping by volatility, by stateful against stateless, and by team responsibility, and keeping stateless objects recreatable.

Choosing the tool

Situation Tool Since

The object stays in the same configuration and state, only its address changes

moved block

Terraform 1.1

An object must stop being managed, but the real object must stay

removed block with lifecycle { destroy = false }

Terraform 1.7

An object must be destroyed and the block removed, possibly with a last action on the way out

removed block with destroy = true (default) and a destroy-time provisioner

Terraform 1.7, provisioners 1.9

An existing object must start being managed in this configuration

import block (see Import and Query)

Terraform 1.5

An object moves from one configuration or state to another

removed in the source, import in the destination

Terraform 1.7

The same, but you cannot edit the configuration or you are on an old release

terraform state mv, terraform state rm, terraform import (legacy)

all releases

A module wants to retire an input or an output without breaking callers

deprecated argument

Terraform 1.15, OpenTofu 1.10

An object must be rebuilt on purpose

terraform apply -replace=ADDRESS (see CLI Essentials)

not a refactoring tool

flowchart TD Q1{"Does the real object keep existing and stay managed by this configuration?"} Q1 -->|"yes"| Q2{"Is only the address changing?"} Q2 -->|"yes"| MV["Add a moved block and keep it"] Q2 -->|"no, attributes change"| NORMAL["Edit the resource and plan as usual"] Q1 -->|"no"| Q3{"Should the real object be destroyed?"} Q3 -->|"yes"| Q4{"Is a last action needed first?"} Q4 -->|"yes"| RM1["removed block, destroy true, with a destroy-time provisioner"] Q4 -->|"no"| DEL["Delete the resource block"] Q3 -->|"no"| Q5{"Does another configuration take it over?"} Q5 -->|"yes"| RM2["removed block with destroy false here, import block there"] Q5 -->|"no, it is just forgotten"| RM3["removed block with destroy false"]

The moved block

A moved block records, in code, that an object that used to live at one address now lives at another. It was added in Terraform 1.1, where the release notes describe it as a way for module authors to record address changes so that, during planning, Terraform migrates existing objects to the new addresses automatically.

moved {
  from = <old address>
  to   = <new address>
}

The block has exactly two arguments and no labels. Both are required.

Argument Meaning

from

The previous address. The reference syntax can select modules, resources, and resources inside child modules, and, by adding an instance key, a single instance.

to

The new address, with the same syntax.

How Terraform evaluates it

The reference page describes the mechanism in two sentences. Before creating a plan for the resource at the to address, Terraform checks the state for an existing object at the from address. If there is one, Terraform treats it as if it had originally been created at the new address, so the plan has no reason to destroy it.

Four consequences follow, and they explain most of the behaviour you will see:

  • The block is declarative and idempotent. It states a fact about history. Once the state has an object at the new address, the block does nothing. A configuration whose state never had the old address ignores it: the refactoring documentation says new instances of a module "which never had" the old resource ignore the block and plan the new object normally.

  • Every copy of the state migrates on its own. There is no coordination step. Staging, production and a developer’s sandbox each rename their own binding the next time they plan with the new code. That is the reason moved replaced terraform state mv for team work: the state mv documentation itself warns that you must communicate carefully with your coworkers so nobody applies an in-between configuration.

  • The move is visible in the plan. The tutorial shows lines such as "has moved to" for a plain move and "will be updated in-place" followed by "(moved from …​)" when the move comes with an attribute change. Review those lines in the pull request like any other change.

  • It is machine readable. In the JSON plan representation, a resource_changes entry whose address has changed carries a previous_address field, "included only if the address has changed, e.g. by handling a moved block". CI can use it to prove that a plan only moved things (see Making refactors safe in CI).

Rename a resource

The simplest case, and the one in the opening picture. Change the label of the resource block and record the old one:

terraform {
  required_version = ">= 1.15"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
  }
}

resource "docker_image" "catalog_api" {
  name = "bookshelf/catalog-api:1.4.0"
}

# The block used to be called "api". Both names are kept here as history.
resource "docker_container" "catalog_api" {
  name  = "bookshelf-catalog-api"
  image = docker_image.catalog_api.image_id
}

moved {
  from = docker_container.api
  to   = docker_container.catalog_api
}

Because both addresses name the resource as a whole, the move covers all of its instances. The documentation uses a resource with count = 2 to make the point: aws_instance.a[0] is treated as aws_instance.b[0] and aws_instance.a[1] as aws_instance.b[1], without listing them.

The plan afterwards, abbreviated and illustrative, contains a single comment line and no actions:

$ terraform plan

  # docker_container.api has moved to docker_container.catalog_api
    resource "docker_container" "catalog_api" {
        name = "bookshelf-catalog-api"
        # (the other attributes are unchanged)
    }

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

Rename a module call

A module call is renamed the same way, and every resource inside follows, because the module path is a prefix of every address in it:

module "cache" {
  source = "./modules/redis"

  name = "bookshelf-cache"
}

moved {
  from = module.redis
  to   = module.cache
}

With this block module.redis.docker_container.this is treated as module.cache.docker_container.this, and, if the call used count or for_each, all instances move without being listed. After renaming a module call, run terraform init again so the working directory registers the new name; the tutorial does this after its own module rename.

Move a resource into a module

Extracting resources into a module is the refactoring teams do most. The code that used to be in the root module becomes a child module, and each resource needs a moved block from its root address to its address inside the module:

# Root module, before: Redis lived directly in main.tf
#
#   resource "docker_container" "cache" {
#     name  = "bookshelf-cache"
#     image = docker_image.redis.image_id
#   }

# After: the container is declared inside modules/redis as docker_container.this
module "cache" {
  source = "./modules/redis"

  image_id = docker_image.redis.image_id
  name     = "bookshelf-cache"
}

moved {
  from = docker_container.cache
  to   = module.cache.docker_container.this
}

The destination may be renamed on the way (cache in the root became this in the module), as the example shows, because a moved block is just two addresses. The official tutorial moves an EC2 instance and its data source into a new child module in the same way and reports aws_instance.example has moved to module.ec2_instance.aws_instance.example.

The module resolution rule matters in larger trees: Terraform resolves module references in a moved block relative to the module instance in which the block is defined. If the block lives in a child module named module.original, the reference module.x.aws_instance.a means module.original.module.x.aws_instance.a, and a module may only make moved statements about its own objects and the objects of its child modules.

Enable count or for_each, and switch between them

Adding count or for_each to a resource changes its addresses, because the instance gets a key. The existing single object is bound to the address without a key, so it has to be assigned a key. The documentation puts it directly: to preserve the object that was previously associated with the plain address, add a moved block that specifies which instance key the object takes in the new configuration.

locals {
  reviews_api_replicas = {
    primary   = { external_port = 8081 }
    secondary = { external_port = 8082 }
  }
}

resource "docker_container" "reviews_api" {
  for_each = local.reviews_api_replicas

  name  = "bookshelf-reviews-api-${each.key}"
  image = docker_image.reviews_api.image_id

  ports {
    internal = 8080
    external = each.value.external_port
  }
}

# The single container that existed before becomes the "primary" replica.
moved {
  from = docker_container.reviews_api
  to   = docker_container.reviews_api["primary"]
}

Terraform keeps the old container as primary and only plans to create secondary. Without the block the plan would destroy the old container and create two new ones.

The rule is that when at least one of the two addresses includes an instance key, Terraform reads both addresses as specific instances rather than whole resources. That one rule covers every switch, and the documentation lists these valid blocks:

# Both old and new configuration used for_each; the "small" element was renamed to "tiny".
moved {
  from = aws_instance.b["small"]
  to   = aws_instance.b["tiny"]
}

# The old configuration used count and the new one uses for_each.
moved {
  from = aws_instance.c[0]
  to   = aws_instance.c["small"]
}
moved {
  from = aws_instance.c[1]
  to   = aws_instance.c["tiny"]
}

# The old configuration used count, the new one uses neither, and only the object at index 2 is kept.
moved {
  from = aws_instance.d[2]
  to   = aws_instance.d
}

Applied to the Bookshelf, a count of replicas converted to named keys looks like this. Each index is mapped to a key explicitly, because there is no rule that could guess it:

resource "docker_container" "web" {
  for_each = toset(["eu", "us"])

  name  = "bookshelf-web-${each.key}"
  image = docker_image.web.image_id
}

moved {
  from = docker_container.web[0]
  to   = docker_container.web["eu"]
}

moved {
  from = docker_container.web[1]
  to   = docker_container.web["us"]
}

This is also the standard remedy for the weakness of count that Meta-Arguments: count, for_each and depends_on describes: indexes shift when an element is removed from the middle of a list, so teams move to for_each with stable keys, and the moved blocks carry the existing objects across without recreating any of them.

A detail worth knowing: when you add count to a resource that did not have it, and you write no moved block that mentions the resource, Terraform on its own proposes to move the original object to instance 0. The documentation still recommends writing the block explicitly, so that future readers of the module see the change.

The same works for module calls. This example moves the objects of a single module call to instance 2 of the new count, so that Terraform creates only module.a[0] and module.a[1]:

module "a" {
  source = "../modules/example"
  count  = 3

  # (module arguments)
}

moved {
  from = module.a
  to   = module.a[2]
}

Split a module and move between modules

A module that has grown too large is split in two. The documentation’s recipe has three steps: write the new modules with copies of the resources they will contain, then edit the original so that it holds only a shim that calls the new modules, and put the moved blocks in the shim. For the Bookshelf, a platform module that created the network and the PostgreSQL volume together is split into network and data:

# modules/platform/main.tf -- now only a backward-compatible shim
module "network" {
  source = "../network"

  name = var.name
}

module "data" {
  source = "../data"

  name = var.name
}

moved {
  from = docker_network.bookshelf
  to   = module.network.docker_network.this
}

moved {
  from = docker_volume.postgres_data
  to   = module.data.docker_volume.postgres
}

When someone upgrades to the shim version, Terraform notices the moved blocks and behaves as if the objects had originally been created inside the new modules. New users can call either the shim or the two new modules directly. The documentation suggests telling existing users that the old module is now deprecated.

It also states the cost of the technique. A parent module normally sees a child as a closed box, unaware of which resources are inside it. Writing module.network.docker_network.this in a moved block breaks that rule, which is acceptable only because the three modules are maintained by the same people and distributed together in one module package.

If the destination is a module call that uses count or for_each, the to address must include the instance key of the call:

moved {
  from = docker_container.example
  to   = module.new[2].docker_container.example
}

Moving between modules, in the other direction too. The same two-address syntax covers a resource leaving a module for the root, one module’s resource going to a sibling module, or a whole module call going into another module, as long as the block is written in a module that contains both objects, because a module may only make statements about its own objects and those of its child modules. The simplest place for such a block is the root module, whose view includes everything. The release notes of Terraform 1.3 add that moved blocks can describe resources moving to and from modules that come from separate module packages.

Move between resource types

Different resource types have different schemas, so objects of different types are normally not compatible. The refactoring page says you can always use moved to rename a resource, but that some providers also let you change an object from one resource type to another, and tells you to check the provider documentation for the supported pairs.

This was added in Terraform 1.8: a moved block can move between two resource types only if the provider of the target type declares that it can convert from the source type. OpenTofu added the same capability in 1.10, with automatic migration of the state data. Terraform 1.9 added one built-in case, moving the null_resource of the hashicorp/null provider to the terraform_data resource of the built-in provider:

resource "terraform_data" "seed_catalog" {
  triggers_replace = [var.seed_version]

  provisioner "local-exec" {
    command = "./scripts/seed-catalog.sh"
  }
}

moved {
  from = null_resource.seed_catalog
  to   = terraform_data.seed_catalog
}

Two limits apply. A moved block cannot turn a managed resource into a data source, and a pair of types that no provider documents as convertible is not made to work by the block. terraform_data itself is covered in Provisioners, Actions and Post-Apply Operations.

Chaining, history and removing moved blocks

If the same object is renamed twice, the documentation recommends chaining the blocks rather than editing the first one:

moved {
  from = docker_container.api
  to   = docker_container.catalog
}

moved {
  from = docker_container.catalog
  to   = docker_container.catalog_api
}

Chaining lets a configuration whose state still has docker_container.api and one that already has docker_container.catalog both end up at docker_container.catalog_api.

What about deleting old moved blocks? The documentation is clear on both sides. Removing a moved block is a breaking change: any configuration that still refers to the old address plans to delete the existing object instead of moving it, and it strongly recommends keeping all historical moved blocks from earlier versions to preserve the upgrade path of users of any previous version. It can be safe to remove them for private modules in an organisation when you are certain that every user has run terraform apply with the new module version.

A practical way to organise them is one moved.tf file per module (or per configuration) that only holds moved blocks, oldest first, each with a short comment saying why and in which release it happened. In a root configuration that only you run, the history can be removed after the change has been applied in every environment.

Keywords and reserved names

Since Terraform 1.10, moved blocks respect reserved keywords when parsing resource addresses. If a resource type name matches a top-level block or keyword of the moved block, you must prepend the resource. identifier to the reference. This only affects unusual type names, but a plan that suddenly fails to parse an old moved block after an upgrade points to it.

Pitfalls of moved blocks

  • Forgetting the block. The most common mistake is to rename first and notice the destroy in the plan. If the plan shows both a destroy and a create for objects with the same attributes, a moved block is almost certainly missing. Read the plan before approving it.

  • Forgetting the instance key. A move into a module that uses for_each needs the key in the to address; a move that adds for_each needs the key too. An unkeyed address on one side and a keyed address on the other is how you tell Terraform that you mean instances.

  • Moving managed resources to data sources. Not possible.

  • Moves across state files. A moved block works inside one configuration and one state. To change which state owns an object, use removed and import (below), or the legacy state mv with explicit state files.

  • Deleting history too early. See above: it silently turns a rename into a destroy for anyone who skips a release.

  • Mixing a move with a different change in a single step. It works, and the tutorial shows an in-place update together with a move, but a move on its own produces a plan that is trivially reviewable. Separate commits help.

The removed block

The removed block, available since Terraform 1.7, is the inverse of a resource or module block. Where deleting a resource block means "destroy this", a removed block says explicitly what should happen to the object and leaves the decision in the code, in a plan that can be reviewed.

removed {
  from = <resource or module address>

  lifecycle {
    destroy = <true or false>
  }
}

Arguments

Argument Meaning

from

Required string: the address to remove. It follows the resource addressing rules and must not include an instance key for a resource that has several instances; the instructions for removing a resource from state say so explicitly. A module address such as module.example is accepted.

lifecycle

Block, required by the Terraform reference. The only supported argument is destroy. The default is true: remove the resource from state and destroy the real one. false removes it from state and leaves the real object alone, which the page suggests for handing management to another tool or team. lifecycle is a meta-argument.

connection

Optional connection settings (ssh or winrm) for the provisioners, either at the removed level, where it is a default for all provisioners, or inside one provisioner.

provisioner

Optional blocks. Only destroy-time provisioners are supported, so when = destroy is required. The provisioner types documented are local-exec and remote-exec, with on_failure and connection available for all.

Forget an object: destroy = false

The standard use is stop managing an object while it keeps running. The instructions for removing a resource from state list the steps: replace the resource block with a removed block, set from to the address, add the lifecycle block with destroy = false, delete references to the resource’s attributes (running terraform validate finds them), and apply. For the Bookshelf, suppose that the covers bucket is going to belong to a storage team:

# main.tf: the "resource aws_s3_bucket covers" block has been replaced by this
removed {
  from = aws_s3_bucket.covers

  lifecycle {
    destroy = false
  }
}

The plan, abbreviated and illustrative, states the intent in the words quoted in the official refactoring page: the resource will no longer be managed by Terraform, but will not be destroyed, because destroy = false is set in the configuration. The reference to the bucket in other resources must have been removed first, otherwise validation fails.

The documentation also recommends removed over the terraform state rm command because it lets you preview the result, which makes it the safer way. If you want to manage the object again later, it must be imported back, as described in Import and Query.

Removing a module. The module documentation says the same of a whole module. Deleting a module block makes Terraform destroy every resource the module manages and remove it from state; replacing the call with a removed block that has destroy = false keeps all of the real objects:

removed {
  from = module.legacy_cache

  lifecycle {
    destroy = false
  }
}

Destroy with a last action: provisioners

With destroy = true the block is not useless. It documents the removal in code and, since Terraform 1.9, it can carry destroy-time provisioners: actions to run when the object is destroyed, even though the resource block that originally declared them is gone. The official example prints a message on the machine that runs Terraform:

removed {
  from = aws_instance.example

  lifecycle {
    destroy = true
  }

  provisioner "local-exec" {
    when    = destroy
    command = "echo 'Instance ${self.id} has been destroyed.'"
  }
}

The Bookshelf version retires the reviews-api container and deregisters it from a service list through a script. The provisioner can read the removed object’s attributes through self:

removed {
  from = docker_container.reviews_api

  lifecycle {
    destroy = true
  }

  provisioner "local-exec" {
    when    = destroy
    command = "./scripts/deregister.sh"

    environment = {
      CONTAINER_NAME = self.name
    }
  }
}

The reference page adds cautions that apply here too. HashiCorp recommends configuration management tools over provisioners, SSH host keys are not validated unless you set host_key, and the command argument should not be built from Terraform variables by substitution, because that invites shell injection; pass values through environment as above. The release notes of Terraform 1.9 also record a fix for removed blocks with provisioners that did not execute when the resource was in a nested module, which is a reason to use at least a current patch release. Provisioners and their alternatives are covered in Provisioners, Actions and Post-Apply Operations.

Hand an object to another configuration: removed plus import

The most important use of the pair is a move between states, the job that a moved block cannot do. The official refactoring page recommends it over the command line because the blocks keep a record of the configuration history. It requires Terraform 1.7 or newer. The procedure, adapted to the Bookshelf (the covers bucket goes from the platform configuration to a new storage configuration):

# In the platform (source) configuration: save a backup of the latest state first.
terraform state pull > terraform.tfstate.backup

# Check which attribute the provider uses to import this resource type.
terraform state show aws_s3_bucket.covers
# platform/main.tf: replace the resource block with a removed block.
removed {
  from = aws_s3_bucket.covers

  lifecycle {
    destroy = false
  }
}
# Plan: it must say the bucket is no longer managed and is not destroyed. Then apply.
terraform plan
terraform apply
# storage/main.tf (destination): declare the resource and import the existing object into it.
resource "aws_s3_bucket" "covers" {
  bucket = "bookshelf-covers-example"
}

import {
  to = aws_s3_bucket.covers
  id = "bookshelf-covers-example"
}
# Plan: it must say "1 to import" with no add, change or destroy. Then apply.
terraform plan
terraform apply

The order matters. Remove from the source state first and apply, then import into the destination; if both states claimed the bucket at once, two bindings would point to one object, which breaks the one-to-one rule from the state page. After the migration you can keep the removed and import blocks as a record of the lifecycle or delete them; the documentation allows both. Resources in the source configuration that referred to the bucket must switch to a data source or to an output read through terraform_remote_state, which is exactly the "identify dependencies" step of the same page. The backends that allow this are described in Backends and Remote State.

Differences in OpenTofu

OpenTofu has the same block and extended it in 1.10 so that it can include lifecycle and provisioner configuration. The differences that matter:

  • The lifecycle block is not required, but is highly recommended: OpenTofu warns when a removed block has none. Terraform’s reference marks it as required.

  • destroy = true behaves as if the resource had been deleted from the configuration, which OpenTofu documents as allowed so that such a block can serve as a historical hint.

  • The from address cannot include an instance key.

  • The provisioner of a removed block runs only when both lifecycle.destroy = true and when = destroy are set; in any other case it is skipped. Its command can reference only the self object, each.key and count.index, the information still held in the state.

  • OpenTofu 1.12 also lets a live resource carry lifecycle { destroy = false }; see Lifecycle, Validation and Checks for that and for how it compares with the removed block.

  • The plan and apply summaries of OpenTofu 1.10 include a count of instances that are being "forgotten".

The legacy tools: state mv and state rm

Before configuration-driven blocks existed, every refactoring was done with commands that edit the state directly. They are still available, and the documentation describes them as legacy for state-file moves. They remain useful for a few cases, and they are hazardous in teams.

Command What it does Configuration-driven replacement

terraform state mv SOURCE DESTINATION

Changes a binding so that an existing object is tracked by a different resource instance. The source and destination must be the same kind of object (resource instance to resource instance, module instance to module instance) and, for resources, the same resource type.

moved block inside one state; removed plus import between states.

terraform state rm ADDRESS...

Removes the binding to an existing remote object without destroying it. The next plan will try to create a new object for each forgotten instance.

removed block with destroy = false.

terraform import ADDRESS ID

Adds a binding for an existing object.

import block.

The documentation for state rm says it directly: instead of using the command, you can use removed blocks; you can remove more than one resource at a time and review the removals in your normal plan and apply workflow.

The reasons to prefer the blocks are practical:

  • They are planned and reviewed. A state mv happens immediately and is invisible in a pull request.

  • They travel with the code, so every state copy migrates itself. The state mv documentation requires you to coordinate with colleagues so that nobody applies an in-between configuration.

  • They leave a record of what happened and when.

  • They work in automation where you cannot (and should not) run state commands against a remote backend.

Still, there are cases for the commands: an old release without the blocks, an emergency repair when you cannot change the configuration yet, and the explicit two-file form for moving objects between local states. A safe sequence, shown for the rename from the opening picture, is:

# 1. Save a backup of the remote state.
terraform state pull > backup-before-refactor.tfstate

# 2. Report what would move; nothing is changed.
terraform state mv -dry-run docker_container.api docker_container.catalog_api

# 3. Move the binding, then confirm that the plan has no changes.
terraform state mv docker_container.api docker_container.catalog_api
terraform plan -detailed-exitcode   # exit status 0 means no changes

The last line uses -detailed-exitcode, which changes the exit codes of plan to 0 (succeeded with an empty diff), 1 (error) and 2 (succeeded with a non-empty diff). Moving objects between two state files with the -state and -state-out options is documented as a legacy path: the official page warns that those options are kept for backwards compatibility, that the method also relies on state pull and state push with some risk of corrupting remote state, and that removed and import blocks are recommended for new migrations. The commands themselves are described in State.

Retiring variables and outputs: the deprecated attribute

External refactoring begins with the module’s interface. Renaming an input variable breaks every caller at once, because the old name no longer exists. The deprecated argument, available in Terraform 1.15 and in OpenTofu since 1.10, turns that single break into two steps: first the old name still works and warns, and later it is removed in a major release.

Block Behaviour

variable

deprecated is a string with the reason. Terraform shows it when a value is set for the variable in a root module or when a child module caller passes a value for it.

output

deprecated is a string with the reason. Terraform shows it when another module uses the output. It can only be added to output blocks in child modules, not the root module, and there is no warning inside the module that defines it: only consumers see it.

module call

ignore_nested_deprecations is a boolean (default false). When true, Terraform shows no deprecation warnings in the module call or its nested modules.

The postgres module of the Bookshelf renames db_pass to database_password and the output connection_url to database_url. Release 1.5 of the module keeps both and deprecates the old ones, so callers see a warning and have time to migrate:

# modules/postgres/variables.tf (release 1.5.0)
variable "db_pass" {
  type       = string
  default    = null
  sensitive  = true
  deprecated = "db_pass is deprecated, use database_password instead."
}

variable "database_password" {
  type      = string
  default   = null
  sensitive = true
}

locals {
  # The new name wins; the old one is the fallback during the deprecation period.
  effective_password = var.database_password != null ? var.database_password : var.db_pass
}

# modules/postgres/outputs.tf (release 1.5.0)
output "connection_url" {
  value      = local.connection_url
  sensitive  = true
  deprecated = "connection_url is deprecated, use database_url instead."
}

output "database_url" {
  value     = local.connection_url
  sensitive = true
}

Both variables carry sensitive = true; marking only the new one would leak the old value into plans. A caller who still passes db_pass now sees the message, and a caller who reads module.postgres.connection_url sees the output’s message. A team that cannot act yet, for instance because the old name is used in a module they do not own, can silence the nested warnings for that call while it works on the migration:

module "postgres" {
  source  = "git::https://example.com/bookshelf/terraform-docker-postgres.git?ref=v1.5.0"

  db_pass = var.postgres_password

  ignore_nested_deprecations = true
}

Deprecation is not limited to your own modules. Terraform 1.15 improved the detection of deprecated resource attributes and blocks and includes the deprecation messages set by providers in the warning text, and OpenTofu 1.12 warns for references to attributes or blocks that the provider schema marks as deprecated, unless disabled by the -deprecation= option. Later Terraform releases kept refining the behaviour: 1.16 allows a module under test to use deprecated outputs and no longer warns for child-module outputs whose deprecated nested attributes are not referenced. Treat these warnings as a to-do list for your next refactoring.

Variables, outputs and the other arguments of those blocks are described in Variables, Outputs and Locals.

Module breaking changes and major versions

A published module is used by people who upgrade when they choose to. For them, the module’s contract is not only its variables and outputs. It also includes its resource addresses, because those decide whether an upgrade is a clean plan or a destroy. External refactoring is the discipline of changing that contract in a way the consumers can absorb.

The table lists the usual changes and what a careful author does about each. The version rules come from the section on versioning in Module Development and Publishing, where the semantic versioning table is the reference.

Change in the module Version What makes it safe

Add an optional variable with a default that keeps the old behaviour

minor

Nothing: existing callers are unaffected.

Rename a resource, move it into a nested module, or change count or for_each keys

minor, with a moved block; major without

The moved block maps old addresses to new, so the consumers' next plan has no destroy. Without it the plan destroys and recreates the object.

Rename or remove a variable or an output

major

Deprecate in a minor release first, remove in the next major one, and say in the migration notes what replaces it.

Make an optional variable required, tighten its type, or change a default so that something different is created

major

Consumers' plans change. State the new requirement in the release notes.

Raise required_version or the minimum of a provider across a major version

major, or minor for core

Consumers must upgrade their tools first. A provider major version often brings its own breaking changes.

Remove a deprecated variable or output

major

The deprecation was the warning; the removal is the break.

Remove historical moved blocks

major

A consumer upgrading from a version older than the one that introduced the move would plan a deletion.

A deprecation timeline for the postgres module

The following sequence applies the pieces of this page in a life cycle of three releases. It is a convention, not a requirement of the tool.

Release Content

1.5.0

Introduces database_password and database_url, deprecates db_pass and connection_url with messages that say what replaces them, renames the internal resource from docker_container.pg to docker_container.this with a moved block, and adds a migration paragraph to the changelog.

1.6.0

Fixes bugs only. All moved blocks stay in the module.

2.0.0

Removes db_pass and connection_url. Keeps the historical moved blocks, because consumers might still upgrade from 1.4.x straight to 2.0.0. The changelog says exactly what each caller must change.

Consumers pin a range such as version = "~> 1.5" and move to the next major version deliberately. For a version that cannot be applied directly, the module can offer both shapes for a while: the shim technique from the split example, in which the old module becomes a thin wrapper around the new ones.

Upgrading a module version safely

On the consumer side, a major upgrade is a refactoring of the root configuration and deserves the same discipline:

  1. Read the changelog and migration notes of the new version, then change version in the module block.

  2. Run terraform init -upgrade so that the new module version is downloaded.

  3. Run terraform plan and read it for destroys. Objects that the author moved show up as "has moved to". Objects that the author forgot to move show up as a destroy and a create.

  4. If the author forgot a move, you can write the moved block yourself in the root module, because the root can address the module’s resources. This repairs a missing history without waiting for the author.

  5. Apply, then keep the block for as long as anyone in your organisation may still run the old version.

The same applies to provider major versions: the upgrade is an external change, and a required_providers constraint change is followed by terraform init -upgrade and a plan that you read before apply. The provider’s own upgrade guide lists the arguments that were renamed or removed. The tests that guard modules against regressions are the subject of Testing.

Making refactors safe in CI

A refactor has one acceptance test: the plan contains no destroy and no create for the objects you meant to keep. That can be checked mechanically by saving a plan and inspecting its JSON representation, which is a documented stable format. A script that fails the pipeline on any deletion in a plan labelled as a refactor is a simple guard:

#!/usr/bin/env bash
# Fails when the plan would delete or replace any resource instance.
set -euo pipefail

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

deletions=$(jq '[.resource_changes[]
                 | select(.change.actions | index("delete"))
                 | .address] | length' refactor.plan.json)

if [ "$deletions" -ne 0 ]; then
  echo "Refactor would delete $deletions object(s); a moved block is probably missing." >&2
  jq -r '.resource_changes[]
         | select(.change.actions | index("delete"))
         | .address' refactor.plan.json >&2
  exit 1
fi

# For information: which instances were moved by moved blocks.
jq -r '.resource_changes[]
       | select(.previous_address != null)
       | "\(.previous_address) -> \(.address)"' refactor.plan.json

A replacement appears in actions as both delete and create, so the filter above catches replacements as well. Pair the script with a -detailed-exitcode check when the expectation is a plan with no changes at all. Wiring the checks into a pipeline is covered in CI/CD and Automation, and the plan file and JSON formats in Dependency Graph and Plan.

A safe refactoring workflow

Step Why

Make sure the working tree is clean and that nobody else is applying.

Concurrent applies against the same state are the common source of conflicts. Locking prevents simultaneous writes, but not an in-between configuration committed by a colleague.

Back up the state: terraform state pull > backup.tfstate.

moved and removed are safe, but a backup is the only undo for a mistake in a hand-written command.

Make one kind of change per commit: renames, then the move into a module, then the count to for_each switch.

Each plan is then trivial to review, and a mistake is easy to bisect.

Add the moved or removed blocks in the same commit as the code change.

The block must be in the code that every environment will run next.

Run terraform plan and read it for destroys, creates and "moved" lines. Run it for every environment.

Environments differ in their count and for_each inputs. A move that is right in dev can be incomplete in prod.

Apply in a lower environment first.

The state of a lower environment is a cheap rehearsal of the production migration.

Keep the blocks until every state has applied them.

Removing them earlier turns a rename into a deletion for the stragglers.

Record the refactor in the changelog and, for modules, in the migration notes.

External refactoring is a communication problem as much as a technical one.

A Bookshelf refactoring in one file

The following refactor.tf brings the previous examples together as they might appear in the root module after a release of the Bookshelf configuration: the postgres container moved into a module, the reviews-api container turned into a for_each set, a call renamed, and the legacy Redis module retired without touching the running container.

# refactor.tf -- history of address changes for the Bookshelf root module.
# Oldest entries first. Keep them until every environment has applied them.

# 2026-03: Redis wrapper renamed (module call).
moved {
  from = module.redis
  to   = module.cache
}

# 2026-05: PostgreSQL container extracted into modules/postgres.
moved {
  from = docker_container.postgres
  to   = module.postgres.docker_container.this
}

# 2026-07: reviews-api becomes a set of replicas; the old single container is "primary".
moved {
  from = docker_container.reviews_api
  to   = docker_container.reviews_api["primary"]
}

# 2026-09: the old session cache is handed to the platform team; the container keeps running.
removed {
  from = module.legacy_sessions

  lifecycle {
    destroy = false
  }
}

The dates are placeholders; the point is the format, a single place where the history of addresses is kept and where a newcomer can find out why something is addressed the way it is.

Terraform and OpenTofu

Topic Terraform OpenTofu

moved block

Since 1.1. Cross-type moves since 1.8 when the provider declares conversion. null_resource to terraform_data since 1.9.

Same block and syntax. Cross-type moves, with automatic state migration, since 1.10.

removed block

Since 1.7. lifecycle required. Destroy-time provisioners since 1.9.

lifecycle optional but recommended, with a warning. lifecycle and provisioner support since 1.10. Provisioner runs only with destroy = true and when = destroy.

destroy in a live resource’s lifecycle

See the lifecycle page for the current state in Terraform.

Since 1.12.

deprecated on variable and output

Since 1.15. ignore_nested_deprecations in the module block.

Since 1.10.

Deprecated provider attributes

Warning text includes the provider’s message (1.15).

Warns on references; disable with -deprecation= (1.12).

Docs for refactoring

developer.hashicorp.com/terraform

opentofu.org/docs, with the same use cases and addressing syntax

The OpenTofu refactoring page covers the same material as the Terraform one: that OpenTofu by default understands moving or renaming an object as an intent to destroy the one at the old address and create one at the new address, and that moved blocks record where an object was known historically. OpenTofu also changes how conditional single resources are migrated: moving from count to the OpenTofu-only lifecycle.enabled argument needs no moved block, as described in Lifecycle, Validation and Checks. The tool differences in general are the subject of OpenTofu.

What changed recently

Version Change

Terraform 1.1

moved blocks for refactoring within modules. Bug fixes in the 1.1 series covered nested modules and -target.

Terraform 1.3

moved blocks can describe resources moving to and from modules in separate module packages. Terraform reports more explicitly when it proposes to delete an object because it moved to a resource instance that is not declared.

Terraform 1.5

import blocks, the configuration-driven way to bring objects in.

Terraform 1.7

removed blocks, with lifecycle { destroy }.

Terraform 1.8

moved blocks between resource types, when the provider of the target type supports the conversion.

Terraform 1.9

Destroy-time provisioners in removed blocks, a fix for provisioners in nested modules, no needless refresh when removing an instance targeted by a removed block, and moved from null_resource to terraform_data.

Terraform 1.10

moved blocks respect reserved keywords when parsing addresses; use the resource. prefix when a type name collides.

Terraform 1.15

deprecated on variable and output, and ignore_nested_deprecations in module blocks. A fix so resources removed by a removed block no longer appear under planned_values in the JSON plan.

Terraform 1.16

A module under test can use deprecated outputs; child-module outputs whose deprecated nested attributes are not referenced no longer warn.

OpenTofu 1.10

Cross-type moved, lifecycle and provisioner in removed, and deprecated for variables and outputs.

OpenTofu 1.12

lifecycle { destroy = false } on live resources, the -deprecation= option, and warnings for provider-deprecated attributes.

OpenTofu 1.13

A removed block with an invalid from address and a destroy provisioner reports a configuration error instead of crashing.

No change of the blocks described here is listed in the Terraform 1.17 changelog beyond the general fixes recorded above. The deprecation of the legacy -state and -state-out options and other removed commands is collected, with the other deprecations of the ecosystem, in What Changed and How to Migrate.

Where older material is outdated

Teaching material written before Terraform 1.1 refactors with terraform state mv only, and material written before 1.7 removes objects with terraform state rm. Both commands still work, but the official documentation now presents the blocks as the recommended approach. One of the two books used as a source for this section does not cover import blocks or moved and removed refactoring at all, so its state-editing recipes are the legacy ones; the other treats internal and external refactoring, including moved blocks and major versions, and remains the better guide to the concepts. Neither can mention the deprecated attribute of Terraform 1.15, the cross-type moves of 1.8, or destroy-time provisioners in removed blocks (1.9), which postdate them. When a book and the official page disagree, follow the official page for your version.

Common mistakes

  • Renaming a stateful resource and approving a plan that destroys and recreates it.

  • Writing a moved block in the wrong direction: from is always the old address.

  • Deleting moved blocks after the first apply in a published module.

  • Using state mv in a team without a coordinated apply, or without a state backup.

  • Removing a resource block without destroy = false when the object must survive.

  • Putting an instance key in the from of a removed block.

  • Using removed to move an object between states without importing it into the destination, leaving the object unmanaged.

  • Importing an object into a second state before it has been removed from the first.

  • Renaming a variable or output in a module without a deprecation period.

  • Marking an output of the root module as deprecated; the attribute is accepted only in child modules.

  • Expecting moved to turn a managed resource into a data source, or to convert resource types the provider does not support.