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.
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, |
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 |
Rename the |
Main tools |
|
|
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 |
|
Terraform 1.1 |
An object must stop being managed, but the real object must stay |
|
Terraform 1.7 |
An object must be destroyed and the block removed, possibly with a last action on the way out |
|
Terraform 1.7, provisioners 1.9 |
An existing object must start being managed in this configuration |
|
Terraform 1.5 |
An object moves from one configuration or state to another |
|
Terraform 1.7 |
The same, but you cannot edit the configuration or you are on an old release |
|
all releases |
A module wants to retire an input or an output without breaking callers |
|
Terraform 1.15, OpenTofu 1.10 |
An object must be rebuilt on purpose |
|
not a refactoring tool |
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 |
|---|---|
|
The previous address. The reference syntax can select modules, resources, and resources inside child modules, and, by adding an instance key, a single instance. |
|
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
movedreplacedterraform state mvfor team work: thestate mvdocumentation 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_changesentry whose address has changed carries aprevious_addressfield, "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.
Source: Terraform v1.3 CHANGELOG
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.
Source: Terraform v1.10 CHANGELOG
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
movedblock is almost certainly missing. Read the plan before approving it. -
Forgetting the instance key. A move into a module that uses
for_eachneeds the key in thetoaddress; a move that addsfor_eachneeds 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
movedblock works inside one configuration and one state. To change which state owns an object, useremovedandimport(below), or the legacystate mvwith 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 |
|---|---|
|
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 |
|
Block, required by the Terraform reference. The only supported argument is |
|
Optional connection settings ( |
|
Optional blocks. Only destroy-time provisioners are supported, so |
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
Source: Terraform Docs — Refactor Terraform state, Remove and import and AWS provider — aws_s3_bucket, Import
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
lifecycleblock is not required, but is highly recommended: OpenTofu warns when aremovedblock has none. Terraform’s reference marks it as required. -
destroy = truebehaves 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
fromaddress cannot include an instance key. -
The provisioner of a
removedblock runs only when bothlifecycle.destroy = trueandwhen = destroyare set; in any other case it is skipped. Itscommandcan reference only theselfobject,each.keyandcount.index, the information still held in the state. -
OpenTofu 1.12 also lets a live
resourcecarrylifecycle { destroy = false }; see Lifecycle, Validation and Checks for that and for how it compares with theremovedblock. -
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 |
|---|---|---|
|
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. |
|
|
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. |
|
|
Adds a binding for an existing object. |
|
Source: Terraform Docs — terraform state mv, Terraform Docs — terraform state rm and Terraform Docs — Refactor Terraform state
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 mvhappens immediately and is invisible in a pull request. -
They travel with the code, so every state copy migrates itself. The
state mvdocumentation 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 |
|---|---|
|
|
|
|
|
|
Source: Terraform Docs — variable block reference, Terraform Docs — output block reference and Terraform Docs — module block reference
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 |
minor, with a |
The |
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 |
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 |
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 |
|---|---|
|
Introduces |
|
Fixes bugs only. All |
|
Removes |
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:
-
Read the changelog and migration notes of the new version, then change
versionin themoduleblock. -
Run
terraform init -upgradeso that the new module version is downloaded. -
Run
terraform planand 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. -
If the author forgot a move, you can write the
movedblock yourself in the root module, because the root can address the module’s resources. This repairs a missing history without waiting for the author. -
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: |
|
Make one kind of change per commit: renames, then the move into a module, then the |
Each plan is then trivial to review, and a mistake is easy to bisect. |
Add the |
The block must be in the code that every environment will run next. |
Run |
Environments differ in their |
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 |
|---|---|---|
|
Since 1.1. Cross-type moves since 1.8 when the provider declares conversion. |
Same block and syntax. Cross-type moves, with automatic state migration, since 1.10. |
|
Since 1.7. |
|
|
See the lifecycle page for the current state in Terraform. |
Since 1.12. |
|
Since 1.15. |
Since 1.10. |
Deprecated provider attributes |
Warning text includes the provider’s message (1.15). |
Warns on references; disable with |
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 |
|
Terraform 1.3 |
|
Terraform 1.5 |
|
Terraform 1.7 |
|
Terraform 1.8 |
|
Terraform 1.9 |
Destroy-time provisioners in |
Terraform 1.10 |
|
Terraform 1.15 |
|
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 |
OpenTofu 1.12 |
|
OpenTofu 1.13 |
A |
Source: Terraform v1.17 CHANGELOG, v1.16, v1.15, v1.10, v1.9, v1.8, v1.7, v1.3, v1.1, OpenTofu v1.10, v1.12 and v1.13
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
movedblock in the wrong direction:fromis always the old address. -
Deleting
movedblocks after the first apply in a published module. -
Using
state mvin a team without a coordinated apply, or without a state backup. -
Removing a
resourceblock withoutdestroy = falsewhen the object must survive. -
Putting an instance key in the
fromof aremovedblock. -
Using
removedto 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
movedto turn a managed resource into a data source, or to convert resource types the provider does not support.