TrademarkTrademark
Features
Documentation

Terraform moved blocks: refactoring without pain

Learn how Terraform's ‘moved’ blocks let you rename or relocate resources safely, keep state intact, and refactor infrastructure without downtime.
Sebastian StadilMarch 4, 2026Updated March 31, 2026
Terraform moved blocks: refactoring without pain
Key takeaways
  • Terraform's moved block, introduced in version 1.1, declaratively tells Terraform a resource changed address so it updates state instead of destroying and recreating the resource.
  • A moved block takes just two arguments, 'from' and 'to', and supports renaming resources, moving them into or out of modules, and converting from count to for_each.
  • Unlike the imperative 'terraform state mv' command, moved blocks live in code, work in CI/CD pipelines, apply across all workspaces, and are validated during planning.
  • HashiCorp recommends retaining moved blocks indefinitely, especially in shared modules, to give consumers a clear upgrade path; chained moves document multiple relocations.
  • Limitations include no dynamic generation with for_each, no converting managed resources to data sources, and the rule that each module must contain the moved blocks for its own resources.

Moved blocks are typically used after importing existing infrastructure into Terraform to reorganize resources without destroying them.

The moved block, added in Terraform 1.1, lets you rename or move resources without destroying and recreating them. You put the old and new address in your configuration, and on the next plan Terraform updates its state to match instead of reading the rename as a delete and a create. The infrastructure itself stays put. Before this existed, the same refactor meant running terraform state mv by hand, which was easy to get wrong and left nothing behind in the code.

What moved blocks are and why you need them

The moved block lets you refactor your resource structure without destroying anything. Before it existed, renaming a resource from aws_instance.old_name to aws_instance.new_name made Terraform read the change as "destroy the old one, create a new one," which could mean downtime and data loss.

Moved blocks fix that by spelling out the link between the old and new addresses:

moved {
  from = aws_instance.old_name
  to   = aws_instance.new_name
}

This one declaration tells Terraform to point the state file at the new address without touching the underlying infrastructure. It handles several common refactoring jobs:

  • Renaming resources for better clarity
  • Moving resources into or out of modules
  • Restructuring from count to for_each for more explicit resource mapping
  • Reorganizing module hierarchies
  • Splitting monolithic modules into smaller, focused modules

Moved blocks keep these changes safe and declarative, and they record how your infrastructure changed right there in the code instead of in some external process or a manual state command.

Syntax and implementation details

The moved block has a straightforward syntax that requires just two arguments:

moved {
  from = <old_resource_address>
  to   = <new_resource_address>
}

Both arguments use Terraform's resource addressing syntax and must refer to the same kind of object. The addresses can refer to:

  • Individual resources: aws_instance.example
  • Resource instances using count or for_each: aws_instance.example[0] or aws_instance.example["primary"]
  • Modules: module.networking
  • Resources within modules: module.networking.aws_vpc.main

The moved block fits into the standard Terraform workflow. When you run terraform plan after adding moved blocks, Terraform:

  1. Reads the moved blocks and looks for resources at the "from" addresses in the state
  2. Updates the state in memory to rename those objects to their "to" addresses
  3. Creates a plan based on the updated in-memory state
  4. Shows a helpful message like # aws_instance.old_name has moved to aws_instance.new_name

During terraform apply, those state updates become permanent. Nothing happens to the real infrastructure; only the state mapping changes. Because they run in the normal workflow, moved blocks are automation-friendly and work in CI/CD pipelines, which manual state commands don't.

Practical examples of moved blocks in action

Renaming a simple resource

When you need to rename a resource for better clarity or consistency:

# Before
resource "aws_security_group" "sg" {
  name = "api-security-group"
  # configuration...
}
 
# After
resource "aws_security_group" "api_security_group" {
  name = "api-security-group"
  # configuration...
}
 
moved {
  from = aws_security_group.sg
  to   = aws_security_group.api_security_group
}

Moving a resource into a module

When restructuring your configuration to use modules:

# Before (in root module)
resource "aws_s3_bucket" "logs" {
  bucket = "application-logs"
  # configuration...
}
 
# After
# In modules/storage/main.tf
resource "aws_s3_bucket" "logs" {
  bucket = var.bucket_name
  # configuration...
}
 
# In root module
module "storage" {
  source      = "./modules/storage"
  bucket_name = "application-logs"
}
 
moved {
  from = aws_s3_bucket.logs
  to   = module.storage.aws_s3_bucket.logs
}

Converting from count to for_each

When migrating from index-based to key-based resource creation:

# Before
resource "aws_instance" "server" {
  count         = 2
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
  
  tags = {
    Name = "server-${count.index}"
  }
}
 
# After
locals {
  servers = {
    "web" = { name = "server-0" }
    "api" = { name = "server-1" }
  }
}
 
resource "aws_instance" "server" {
  for_each      = local.servers
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
  
  tags = {
    Name = each.value.name
  }
}
 
moved {
  from = aws_instance.server[0]
  to   = aws_instance.server["web"]
}
 
moved {
  from = aws_instance.server[1]
  to   = aws_instance.server["api"]
}

Renaming a module

When giving a module a more descriptive name:

# Before
module "app" {
  source = "./modules/application"
  # arguments...
}
 
# After
module "frontend" {
  source = "./modules/application"
  # arguments...
}
 
moved {
  from = module.app
  to   = module.frontend
}

These examples show how moved blocks handle a range of refactoring patterns while preserving state continuity and avoiding resource recreation.

Best practices for using moved blocks

Document and retain your moved blocks

Add explanatory comments to moved blocks to document when and why the refactoring occurred:

# Resource renamed from aws_instance.app to aws_instance.web_server
# as part of the application module refactoring on 2024-06-15
moved {
  from = aws_instance.app
  to   = aws_instance.web_server
}

Unlike most other Terraform constructs, HashiCorp recommends you keep moved blocks in your configuration indefinitely, especially in shared modules. That gives a clear migration path to people who might be on different versions of your module.

Use the chained moves pattern

When resources need to move multiple times, use chained moved blocks to document the full history:

moved {
  from = aws_instance.original
  to   = aws_instance.intermediate
}
 
moved {
  from = aws_instance.intermediate
  to   = aws_instance.final
}

This pattern ensures that users can successfully update from any previous version to the current one.

Plan before applying

Always run terraform plan after adding moved blocks to verify that Terraform correctly interprets your refactoring intentions. Look for the move messages in the plan output before proceeding with apply.

Refactor incrementally

For large configurations, refactor resources in smaller batches rather than attempting to move everything at once. This approach makes it easier to identify and troubleshoot any issues that arise.

Consider using module shims for backward compatibility

When breaking a module into smaller pieces, create a shim module that:

  1. Calls the new modules
  2. Contains moved blocks to map resources to their new locations
  3. Provides backward compatibility for existing users

This lets you make a gradual transition without breaking things for the people who use your module.

Limitations and gotchas to be aware of

No dynamic generation of moved blocks

Terraform doesn't support dynamic generation of moved blocks using for_each or similar constructs. For large-scale moves, you might need to generate the moved blocks using external scripts:

#!/bin/bash
# Generate moved blocks for converting from count to for_each
for i in {0..20}; do
  echo "moved {"
  echo "  from = aws_instance.server[$i]"
  echo "  to = aws_instance.server[\"server_$i\"]"
  echo "}"
done > moved.tf

Not all move types are supported

Some limitations exist:

  • You cannot convert managed resources to data sources or vice versa
  • Earlier versions (before 1.3) had limitations with moving resources to external registry modules
  • Provider version constraints may limit certain resource type moves

Module boundaries matter

When refactoring modules, remember that:

  • Each module must include its own moved blocks for resources it contains
  • You cannot place moved blocks in one module to move resources in another module
  • Moving resources between entirely different module packages may require terraform state mv instead

For_each key type changes require special handling

When converting from a numeric index to a string key (count to for_each), be very explicit about the mapping:

moved {
  from = aws_security_group.example[0]
  to   = aws_security_group.example["first"]  # Not aws_security_group.example[first]
}

Missing quotation marks around string keys is a common mistake that can lead to confusing errors.

Working with terraform plan and apply

When you run terraform plan with moved blocks, Terraform processes them early in the planning phase:

  1. Terraform reads the configuration and state
  2. It processes all moved blocks before any other operations
  3. The resource addresses in the state are updated in memory
  4. The planning operation continues using the updated addresses
  5. The plan output includes statements like # aws_instance.old_name has moved to aws_instance.new_name

This makes the moved block operation transparent and predictable. The real state file only changes when you run terraform apply, and that's when the resource addresses are updated for good.

If you add a moved block but the resource at the "from" address doesn't exist in the state, Terraform simply ignores the moved block without any errors. This makes moved blocks safe to add preemptively.

During apply, Terraform handles moved blocks atomically with other state changes, ensuring that the state remains consistent even if the operation is interrupted.

Moved blocks vs. terraform state mv

While both moved blocks and the terraform state mv command accomplish similar results, they have important differences:

Feature moved Block terraform state mv Command
Implementation Declarative, in code Imperative, CLI command
Execution During plan/apply workflow Immediate, outside workflow
Documentation Self-documents in code No code documentation
CI/CD Compatibility Works with automation Requires manual steps or scripting
Workspace Handling Applies to all workspaces Must be run for each workspace
Error Handling Validates during planning No validation before execution
Portability Moves travel with code Requires separate documentation

The moved block is generally preferred for:

  • Shared modules used by others
  • Configurations managed through CI/CD pipelines
  • Teams working with multiple workspaces
  • Scenarios where documenting the change in code is valuable

The terraform state mv command remains useful for:

  • One-time migrations that don't need to be documented in code
  • Complex refactoring scenarios not expressible with moved blocks
  • Legacy Terraform versions (before v1.1)
  • Emergency state repairs

Recent updates and enhancements

Since its introduction in Terraform v1.1, the moved block feature has seen several improvements:

  • Terraform v1.3 (2022): Added support for moving resources to modules sourced from external registries, including the Terraform Registry and private registries.
  • Improved error messaging: Better diagnostic information when moved blocks are incorrectly configured or lead to ambiguous situations.
  • Enhanced documentation: Expanded examples and best practices in the official documentation.
  • Integration with import blocks: Works alongside the newer configuration-driven import feature (introduced in v1.5.0).

As of 2025, Terraform continues to refine the moved block functionality, with ongoing discussions about supporting:

  • Dynamic moved block generation
  • Better handling of nested modules
  • More comprehensive validation during planning

When to use moved blocks, and when not to

Most of the time, moved blocks are the right way to refactor. The address change lives in your code, goes through review like any other change, and applies without recreating resources. They don't cover everything: you can't generate them dynamically, and some moves still need terraform state mv. But for the common job of renaming and reorganizing resources, they're better than editing state by hand.

As your configuration grows across many workspaces and CI/CD pipelines, the platform you run Terraform on matters as much as the refactoring technique itself. See the trade-offs in our guide to choosing a Terraform Cloud alternative. Refactoring is also where pricing models diverge: a moved-block change touching hundreds of resources is still a single run on Scalr, whatever the resource count.

About the author
Sebastian StadilCEO at Scalr
Sebastian Stadil is the CEO of Scalr with 15+ years of DevOps experience. He started with AWS in 2004 and advised early Microsoft Azure and Google Cloud.