TerraformXXV · Migrations and Backend ChangesProduction Terraform
Importing Existing Infrastructure
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
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 changes1— internal error2— 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 importfollowed 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
-
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.
-
The wrong provider-specific ID is used at import.
terraform import aws_s3_bucket.logs acme-logs-2024is correct for the bucket name;terraform import aws_s3_bucket.logs arn:aws:s3:::acme-logs-2024would fail with an ID format error. Symptom: the import command returns an error from the provider. -
The for_each map is non-deterministic.
tolist()over adatasource that returns resources in a different order between plans produces a different plan every time. Symptom: a plan that shows imports on every run. -
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 existserror on every plan. -
The moved block is wrong. The
fromortoaddress has a typo. Symptom:Error: moved block from aws_instnace.web to aws_instance.web: source address not found in state. -
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_eachimport blocks for any bulk adoption above ten resources. The CLIterraform importdoes 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
Q1. What does terraform import do?
Q2. What is the canonical proof that a resource was imported correctly?
Q3. An import block must be removed from the configuration after a successful import.
Q4. Which of the following are appropriate uses of import in a production migration? (Select all that apply.)
Q5. What does a moved block do?
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?
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.