Skip to main content
RunBook Academy

TerraformXXV · Migrations and Backend ChangesProduction Terraform

Importing Existing Infrastructure

Intermediate⏱ ~14 minbash

What you'll learn

  • Adopt a single live resource into Terraform state using terraform import
  • Adopt many resources with an import block and for_each
  • Verify the import produced an empty plan before any apply
  • Use moved blocks to refactor addresses without touching resources
  • Distinguish import (adoption) from managed (declared in configuration)

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

terraform import is the operation that brings a resource that already exists in the provider API under Terraform’s management. It does not create the resource. It does not modify the resource. It writes a record into the state file so that the next terraform plan knows the resource is there and owned by Terraform.

The lesson is short because the operation is short. The failure modes are the long part. Most import-related outages are not caused by the command failing; they are caused by the command succeeding and the operator not verifying what was actually adopted.

What import does and does not do

  Before import                   After import
  ----------------                ----------------
  Resource exists in API          Resource exists in API
  State file: no entry            State file: full entry with
                                  all readable attributes
  Configuration: not present      Configuration: must be
                                  written to match
  Plan: would create the          Plan: empty if the
  resource                        configuration matches the
                                  live API

Import writes the state. It does not write the configuration. The configuration must be authored separately, and the authoring is the part where teams get into trouble.

The terraform import command (CLI)

The command takes two positional arguments: the resource address that Terraform will use, and the provider-specific ID that identifies the resource in the API.

# CONFIGURATION: adopt one existing EC2 instance.
terraform import aws_instance.web i-0123456789abcdef0

The resource address is whatever the configuration will reference. For a top-level resource it is aws_instance.web. For a resource inside a module it is module.network.aws_vpc.main. The provider-specific ID is documented per-provider; for AWS EC2 it is the instance ID, for AWS S3 it is the bucket name, for AWS IAM it is the role ARN.

A common mistake is to choose an address that does not match the configuration that will be written later. Terraform does not validate that the address matches anything; it accepts whatever string you pass and writes it to state. If the configuration later uses a different address, the import is silently orphaned.

Import blocks (Terraform 1.5+)

Import blocks are the declarative, reviewable form of terraform import. They live in the configuration, are version-controlled, and appear in the plan output before any apply.

# network.tf
import {
  to = aws_vpc.main
  id = "vpc-0123456789abcdef0"
}

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

When terraform plan runs, the plan output lists the import as an action:

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

The block is removed from the configuration after the import succeeds; leaving it in place causes the next plan to attempt the import again, which fails because the resource is already in state.

for_each imports: bulk adoption

For resources with no per-resource state worth caring about (security groups, IAM roles, S3 buckets in dev), for_each on an import block scales the operation to hundreds of resources:

# locals.tf
locals {
  security_groups = toset([
    "sg-0123456789abcdef0",
    "sg-0123456789abcdef1",
    "sg-0123456789abcdef2",
  ])
}

# network.tf
import {
  for_each = local.security_groups
  to       = aws_security_group.adopted[each.value]
  id       = each.value
}

resource "aws_security_group" "adopted" {
  for_each = local.security_groups
  name     = "adopted-${each.key}"
  # Other attributes populated to match the live API.
  description = "Adopted by Terraform migration"
  vpc_id      = aws_vpc.main.id
}

The next plan shows the import count without listing every resource individually:

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

The address map (local.security_groups) must be deterministic across plan runs; a tolist() over a non-deterministic source will produce a different plan every time.

Verifying an import: the empty plan

A correct import is verified by terraform plan returning no changes for the adopted resource:

# READ-ONLY: confirm the import produced no diff.
terraform plan -no-color -detailed-exitcode
echo "exit=$?"
No changes. Your infrastructure matches the configuration.
exit=0

The exit-code convention from -detailed-exitcode is the canonical “empty plan” signal:

  • 0 — no changes
  • 1 — internal error
  • 2 — changes were found

If the exit code is 2 after an import, the import is wrong. The configuration disagrees with the live API. Applying at that point will change or destroy the resource.

moved blocks: refactoring without recreating

A moved block tells Terraform that an address in the state file corresponds to a different address in the configuration. The real resource is not touched. The state file is updated in place. The plan shows the move as a no-op.

# Before: state has aws_instance.web at the root.
# After: state has aws_instance.web inside a module.
moved {
  from = aws_instance.web
  to   = module.compute.aws_instance.web
}
Plan: 0 to import, 0 to add, 0 to change, 0 to destroy.

Terraform will perform the following actions:

  # aws_instance.web has moved to module.compute.aws_instance.web
    (no action required)

The moved block is removed from the configuration after the state update is committed. Leaving it in place is harmless (Terraform sees the move has already happened) but clutters the code review.

For module splits (a single state split into multiple workspaces), moved blocks cannot cross state boundaries. The equivalent is terraform state mv against the source state file, followed by removal from the source configuration. The lesson on state migrations covers this in detail.

When import is not the answer

Import is the right tool when the resource is already in the shape Terraform expects and the team wants Terraform to manage it going forward. It is not the right tool when:

  • The resource needs to be recreated. A terraform import followed by a configuration change that requires replacement will trigger a destroy-and-create on the next apply. The migration is then a recreation, not an adoption.
  • The resource has dependencies that are not in state. Importing an EC2 instance without importing its security group, subnet, and IAM role leaves the dependency graph incomplete. The next plan may try to recreate the dependencies.
  • The provider does not support reading the resource. A few providers and resource types do not implement a full read path; the import will succeed with a partial state entry, and the next plan will try to fill in the missing attributes, which may include destroying the resource.

For these cases, the answer is to redesign the configuration or to leave the resource unmanaged.

Production failure modes

  1. The configuration is written before the import. The plan shows the resource as needing in-place updates. The apply replaces the resource. Symptom: an empty plan that becomes non-empty after a configuration edit, applied without re-checking.

  2. The wrong provider-specific ID is used at import. terraform import aws_s3_bucket.logs acme-logs-2024 is correct for the bucket name; terraform import aws_s3_bucket.logs arn:aws:s3:::acme-logs-2024 would fail with an ID format error. Symptom: the import command returns an error from the provider.

  3. The for_each map is non-deterministic. tolist() over a data source that returns resources in a different order between plans produces a different plan every time. Symptom: a plan that shows imports on every run.

  4. The import block is left in configuration after the apply. The next plan tries to import the resource again and fails because it is already in state. Symptom: a resource already exists error on every plan.

  5. The moved block is wrong. The from or to address has a typo. Symptom: Error: moved block from aws_instnace.web to aws_instance.web: source address not found in state.

  6. The provider’s read function does not populate an attribute the configuration requires. The import succeeds with a partial entry, and the plan shows an update against the missing attribute. Symptom: a non-empty plan immediately after import.

What to do in production

  • Always verify the empty plan on a non-production copy of the state before touching production state.
  • Remove import and moved blocks from the configuration after the apply that consumes them.
  • Use for_each import blocks for any bulk adoption above ten resources. The CLI terraform import does not scale to hundreds of resources in a single command.
  • Treat the configuration block as authoritative after the import. The state was adopted; the configuration is written. Drift between them is a configuration bug, not a state bug.
  • For stateful resources, take a backup (snapshot, RDS snapshot, S3 versioning) before the import, even though the import itself does not modify the resource.

Verification

An import is verified by three checks: the import command returns success, the plan is empty, and the resource is reachable through the provider API.

# CONFIGURATION: adopt one EC2 instance.
terraform import aws_instance.web i-0123456789abcdef0
aws_instance.web: Importing from ID "i-0123456789abcdef0"...
aws_instance.web: Import prepared!
  Prepared aws_instance.web for import
aws_instance.web: Refreshing state...

Import successful!

The resources that were imported are shown above. The next
step is to confirm that the imported resources are correctly
configured by running `terraform plan`.
# READ-ONLY: confirm the empty plan.
terraform plan -no-color -detailed-exitcode
echo "exit=$?"
No changes. Your infrastructure matches the configuration.
exit=0
# READ-ONLY: confirm the resource is reachable through the
# provider, not just through Terraform's read.
aws ec2 describe-instances \
    --instance-ids i-0123456789abcdef0 \
    --query 'Reservations[0].Instances[0].State.Name' \
    --output text
running

If the API call fails, the resource is gone or the IAM is wrong. If the plan is non-empty, the configuration is wrong. If both pass, the import is verified.

Knowledge check · 7 questions

  1. Q1. What does terraform import do?

  2. Q2. What is the canonical proof that a resource was imported correctly?

  3. Q3. An import block must be removed from the configuration after a successful import.

  4. Q4. Which of the following are appropriate uses of import in a production migration? (Select all that apply.)

  5. Q5. What does a moved block do?

  6. Q6. A team uses for_each to import 200 IAM roles. Every plan shows 200 imports even after the first apply. What is the most likely cause?

  7. Q7. Which command reveals whether an imported EC2 instance is reachable end to end, beyond what Terraform's read function returns?

Passing score: 75%. Answers are checked in this browser.