TrademarkTrademark
Features
Documentation
  1. Learning Center
  2. The Developer's Guide to HCL

Article · part of a guide

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.

Terraform moved blocks: refactoring without pain

Key takeaways

  1. 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.
  2. 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.
  3. 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.
  4. HashiCorp recommends retaining moved blocks indefinitely, especially in shared modules, to give consumers a clear upgrade path; chained moves document multiple relocations.
  5. 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.

The moved block, added in Terraform 1.1, lets you rename or relocate a resource without destroying and recreating it. You write the old address and the new one, and on the next plan Terraform updates state instead of reading the rename as a delete plus a create; the infrastructure doesn't change. Before 1.1 the same refactor meant terraform state mv by hand, easy to get wrong and invisible in code review.

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 record how your infrastructure changed right there in the code, instead of in an 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

Moved blocks run first. Before planning anything else, Terraform renames the matching entries in an in-memory copy of state, then plans against that copy, so the output shows # aws_instance.old_name has moved to aws_instance.new_name and nothing to destroy. Nothing is written until you apply. And if the from address isn't in state at all, Terraform skips the block without an error, which is why it's safe to ship moved blocks ahead of the consumers who need them. Because all of this happens inside the normal plan and apply, moved blocks work in CI/CD pipelines where a manual state command wouldn'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
}

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.

Two habits that save time: always read the plan output for the has moved to lines before applying, and in large configurations move resources in small batches so a mistake is easy to spot and undo.

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 leads to confusing errors.

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

Reach for terraform state mv only for one-off migrations nobody needs to see in code, moves the block can't express, Terraform older than 1.1, or emergency state repairs. Everything else belongs in a moved block.

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. They're also the natural next step after importing existing infrastructure, once the imported resources need a tidier structure.

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 Stadil

CEO 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.

Part of this guide

9 sheets

The Developer's Guide to HCL

8 articles