Move, Import, Remove, and Recover Managed Resources

Terraform does not manage resources only because they exist in configuration. It manages them because a resource address in configuration is bound to a remote object in state. This lesson teaches how to change that binding deliberately: move an address during refactoring, import an existing object, remove an object from management without destroying it, and recover when state no longer matches reality.

The practical outcome is narrow but important. You should be able to change Terraform’s record of ownership without surprising the provider into deleting, replacing, or duplicating infrastructure. In this course section on providers and state, this is where the abstract state file becomes an operational tool that must be edited through Terraform mechanisms, reviewed in plans, and protected like production data.

State Binding Internals

A Terraform resource address is the full name Terraform uses for a configured object, such as aws_s3_bucket.logs, module.network.aws_vpc.main, or aws_instance.web[0]. State stores that address together with provider-specific data: the remote object ID, current attributes Terraform last observed, provider address, dependencies, and private metadata used by the provider.

During planning, Terraform loads configuration, loads state, asks providers to refresh known remote objects, and compares the desired configuration with the refreshed state. If an address exists in configuration and state, Terraform plans an update, replacement, or no-op. If an address exists in configuration but not in state, Terraform normally plans to create. If an address exists in state but not in configuration, Terraform normally plans to destroy. Move, import, and remove operations change those address-to-object relationships before Terraform reaches the destructive conclusion.

There are two broad ways to make these changes. Declarative blocks, such as moved, import, and removed, live in configuration and are visible in code review. Imperative commands, such as terraform state mv, terraform import, and terraform state rm, change a workspace’s state directly. Prefer declarative blocks when you want repeatable behavior across collaborators and automation. Use state commands mainly for repair work, one-off migration, or older workflows where declarative support is not suitable.

Syntax Anatomy

A moved block maps one Terraform address to another. Terraform rewrites the binding in state during planning, so the remote object follows the new address. The from address must exist in state, and the to address must exist in configuration with a compatible resource type. A good plan then says the object has moved, not that one object will be destroyed and another created.

An import block binds an existing remote object ID to a configured resource address. The to address names the resource Terraform should manage. The id value is interpreted by the provider, so an S3 bucket name, IAM role name, VPC ID, or database identifier may each have different formats. Import does not magically write complete configuration. Your configuration must match the remote object closely enough that the follow-up plan is acceptable.

A removed block tells Terraform to forget an address. With destroy = false, Terraform removes the binding from state but leaves the remote object in place. With destruction enabled, it behaves like removal from configuration and allows Terraform to destroy the object. The non-destroying form is useful when handing a resource to another workspace, another tool, or manual administration.

Example 1: Rename Without Recreating

Suppose a bucket was originally named logs in configuration, but the module now distinguishes audit logs from application logs. Renaming the Terraform block without a move would make Terraform think aws_s3_bucket.logs disappeared and aws_s3_bucket.audit_logs appeared. For many resource types, that means a destroy and create plan.

resource "aws_s3_bucket" "audit_logs" {
  bucket = "example-company-audit-logs"

  tags = {
    Purpose = "audit"
  }
}

moved {
  from = aws_s3_bucket.logs
  to   = aws_s3_bucket.audit_logs
}

The expected deterministic behavior is that Terraform reports the state address move and does not plan to create a second bucket solely because the local name changed. If the bucket’s arguments are otherwise unchanged, the plan should be a no-op after the move. If tags or settings changed at the same time, the plan should show only those updates. The important signal is absence of a destroy/create pair for the renamed object.

Example 2: Move Into A Module

Moving into a module is the same state operation with a longer address. The object keeps its remote ID, but its state binding changes from a root-module address to a child-module address. This is common when a course project grows from a single file into reusable modules.

module "storage" {
  source = "./modules/storage"

  bucket_name = "example-company-audit-logs"
}

moved {
  from = aws_s3_bucket.audit_logs
  to   = module.storage.aws_s3_bucket.audit_logs
}

Terraform can only complete this move if the destination resource exists in the module configuration and has a compatible type. The expected plan should show the bucket moving into module.storage. If the module also changes bucket settings, those differences appear as updates under the new address. If Terraform instead proposes destroying the root resource and creating the module resource, the move address is wrong, the source state is missing, or the destination resource address does not match the real module contents.

Example 3: Adopt Existing Infrastructure

Import is the inverse problem. The remote object already exists, but Terraform has no binding for it. You first write configuration for the object you intend to manage, then declare the import. The provider reads the object by ID and records it under the chosen address.

resource "aws_s3_bucket" "archive" {
  bucket = "example-company-archive"

  tags = {
    Owner = "platform"
  }
}

import {
  to = aws_s3_bucket.archive
  id = "example-company-archive"
}

The expected behavior is an import action followed by any updates needed to make the real bucket match configuration. A clean import is not necessarily a no-op: if the existing bucket lacks the Owner tag, Terraform will plan to add it. That is usually desirable, but it must be reviewed. The dangerous pattern is importing a production object with incomplete configuration and then applying a plan that removes settings Terraform did not yet model.

Example 4: Stop Managing Without Destroying

Sometimes Terraform should forget an object but leave it running. For example, a database might move to a separate workspace with tighter access controls. Removing the resource block alone would normally produce a destroy plan. A removed block with destroy = false changes the state record instead.

removed {
  from = aws_db_instance.reporting

  lifecycle {
    destroy = false
  }
}

The expected plan should say Terraform will no longer manage aws_db_instance.reporting and will not destroy the remote database. After apply, the object remains in the cloud provider, but this workspace has no state binding for it. That means later configuration using the same address would be treated as new unless the object is imported again. Ownership must be transferred deliberately, or the resource becomes unmanaged drift.

Design Choices And Trade-Offs

Declarative state changes make migrations reviewable. They are especially useful in teams because the same migration runs in every workspace that still has the old binding. The trade-off is that these blocks are temporary migration code. Keeping obsolete moved, import, or removed blocks forever can confuse future readers, but deleting them too early can break a workspace that has not yet applied the migration.

Imperative state commands are faster for emergency repair. They also bypass the normal code review trail unless your team records the command, operator, workspace, state serial, and backup. For production state, direct commands should be run with a lock, a freshly pulled state backup, and a clear rollback path. Never run state surgery against a workspace you have not positively identified.

Another trade-off is how much configuration to write before import. Minimal configuration may import quickly, but it often creates a noisy plan afterward. More complete configuration takes longer but reduces accidental changes. For critical resources, inspect the provider documentation for import ID format and exported arguments, then compare the plan against the current remote settings before applying.

Failure Modes And Troubleshooting

Symptom: the plan shows destroy and create after a rename. Cause: Terraform did not match the old state address to the new configuration address. Diagnostics: list state addresses, check module paths, indexes, and resource names, then compare them with the moved block. Correction: fix the from and to addresses and re-plan before applying.

Symptom: import fails with an error saying the object cannot be found. Cause: the provider could not read the object using the supplied import ID, region, account, or credentials. Diagnostics: verify the active workspace, provider alias, region, account identity, and the provider’s documented import ID format. Correction: use the exact provider context that owns the object and update the import ID.

Symptom: import succeeds but the next plan wants to replace the object. Cause: configuration contains an argument that forces replacement and does not match the remote object. Diagnostics: read the plan details for attributes marked as replacement triggers, then compare them with the remote resource. Correction: adjust configuration to match the existing object first, or intentionally schedule a replacement in a separate reviewed change.

Symptom: a removed resource remains in the cloud account and nobody manages it. Cause: destroy = false was applied without completing transfer to another workspace or tool. Diagnostics: search other Terraform states for the remote ID and check provider tags or ownership metadata. Correction: import the object into the new owner or re-import it into the original workspace.

Reliability And Security Implications

State often contains sensitive attributes and always contains operational authority: whoever can rewrite bindings can cause Terraform to act on the wrong object. Store state in a backend with encryption, access control, versioning, and locking. Treat state backups as sensitive data. When importing, use credentials that can read the target object but are not broader than the migration requires.

Reliability depends on serializing state changes. Two concurrent applies that both move or import related addresses can race, especially in local state or weak automation. Remote backends with locking reduce this risk, but operators still need a queueing discipline. Saved plans are also time-sensitive: if state changes after a plan is created, discard the old plan and create a new one.

Hands-On Lab

Prerequisites: a disposable Terraform workspace, the target provider initialized, access to a non-production resource you are allowed to manage, backend locking enabled if using shared state, and permission to read state. Use a sandbox cloud account for provider resources, or adapt the addresses to an internal test provider used by your team.

  1. Create or identify a simple resource already managed by Terraform, such as a test bucket. Run a plan and confirm it is currently stable.
  2. Rename the resource block in configuration and add a matching moved block from the old address to the new address.
  3. Run a plan. Verify that the plan reports a move and does not report a destroy/create pair for that object.
  4. Apply the plan. Then list the state and verify that only the new address remains.
  5. Choose a separate existing test object and write matching configuration for it. Add an import block using the provider’s documented ID format.
  6. Run a plan and inspect every proposed post-import change. Adjust configuration until the plan is either no-op after import or contains only intentional updates.
  7. Apply the import. Verify that the resource address appears in state and that provider-side tags or identifiers match the expected object.
  8. Add a removed block with destroy = false for a disposable managed object. Plan and verify that Terraform will forget it without destroying it.
  9. Apply, then verify both sides: the address is absent from state, and the remote object still exists.
  10. Cleanup by importing the object into its intended workspace, re-importing it into the original workspace, or manually deleting it if it was created only for the lab.

Recovery Workflow

Recovery starts by stopping writes. Do not keep applying while state identity is uncertain. Pull or locate the latest state version, identify the last known good serial, and compare it with the current state. If your backend supports version restore, restore the known good version according to your team’s backend procedure. If only one binding is wrong, a targeted moved block, import, or state command may be less disruptive than rolling back the whole state.

After recovery, always run a fresh plan. A restored state can be internally valid but stale relative to remote infrastructure. The verification target is not merely that Terraform runs; it is that every important remote object is bound to exactly one intended Terraform address and that the next apply contains no unreviewed replacement or destruction.

Assessment Exercises

  1. A developer moves aws_iam_role.app into module.identity and the plan shows one destroy and one create. What exact addresses would you inspect, and what should the corrected migration block prove?
  2. You import a production bucket and Terraform plans to remove encryption settings. Explain why import succeeded but the plan is unsafe, and describe the next two corrections.
  3. When is removed with destroy = false safer than deleting a resource block? Give one case where it would be dangerous.
  4. Design a migration sequence for splitting one root module into two modules while keeping remote objects unchanged. Include how you would verify each phase.
  5. A state restore fixes a bad move, but the next plan still shows drift. Explain why that can happen and how you would decide whether to update configuration, import, or change the remote object.

Summary

Terraform state is the binding between configuration addresses and real provider objects. moved preserves that binding across refactors, import creates a binding for existing infrastructure, removed can delete a binding without deleting the object, and recovery restores or repairs bindings when they become wrong. The safe workflow is consistent: identify the address and remote ID, make the smallest state transition, review the plan for destructive surprises, apply with locking, and verify both state and provider reality.