terraform import: Adopting an Object Terraform Did Not Create
Your company ran its EC2 estate by hand for three years. Someone hands you a running instance — i-0abc123def456 — and a mandate: from now on, Terraform manages it. Nothing about that instance exists in your configuration or your state. terraform import is the bridge: it reads the object from the cloud API, writes its attributes into state under an address you choose, and stops there.
terraform import 'aws_instance.web' 'i-0abc123def456'
aws_instance.web: Importing from ID "i-0abc123def456"...
aws_instance.web: Import complete!
Imported aws_instance (ID: i-0abc123def456)
Import successful!
The imported resources were not imported. Please see above for any warnings.
The command takes exactly two arguments: the address you are importing to, and the provider-assigned ID of the real object — instance ID, bucket name, database identifier. The address must be spelled as a full resource address, module prefixes included (module.network.aws_vpc.main), and with a remote backend the import takes the state lock like any other write. Import also refreshes the object as it imports, so what lands in state is the object's current attributes, not some historical record — the same values Listing and Showing State Entries teaches you to read back with state show. What the ID looks like depends entirely on the resource type, and knowing where to find it is half the job: EC2 uses the instance ID (i-...), S3 uses the bare bucket name, RDS the database identifier, and IAM roles the role name alone — no ARNs, no account numbers, because the provider resolves those from the credentials you already have.
Now the part that surprises everyone once: import writes state, never configuration. No resource block appears in your .tf files. Terraform does not know what arguments you intended; it only knows what the object is. That asymmetry has an immediate consequence. If aws_instance.web exists in state but not in configuration, the next plan sees an unmanaged state entry and proposes destroying the instance — the opposite of adoption. The safe sequence is therefore non-negotiable, and worth stating as a rule:
1. Pick the address and read the object's real ID from the cloud console or CLI
2. terraform import <address> <id> # state entry created
3. Author the matching resource block # configuration catches up
4. terraform plan # reconciliation, addressed next chapter
Steps 2 and 3 belong in the same session — on the same branch, in the same pull request. Teams that import on Friday and write config on Monday are one eager apply from a destroyed production object, because any plan-run in between treats the imported entry as an orphan to clean up. And one operational note: import respects the same credential chain as every other command, so the machine importing must be able to read the object — in accounts your pipeline cannot reach, imports happen locally with short-lived credentials and nothing else runs there. Also note what import does to refresh: it always reads the live object as part of importing, so an instance someone resized in the console last week lands in state with its current type, and your first plan will surface that difference as a diff to classify — which is exactly the next chapter's subject.
import Blocks: Adoption as Declarative, Reviewable Code
Terraform 1.5 introduced a second way to import: the import block, which turns adoption from an imperative command into configuration that plan executes:
import {
to = aws_instance.web
id = "i-0abc123def456"
}
Run terraform plan and the import happens during planning — aws_instance.web: Importing... appears in the plan output alongside the usual refresh — and the object enters state when you apply. The difference from the command-line flow is structural, and it is the reason teams prefer blocks: the intent to import lives in a .tf file, so it is reviewed in the same pull request as the resource block it pairs with, reproduced identically by CI, and auditable from history. terraform import lives in one engineer's shell history; an import block lives in git.
The blocks compose. Adopt twenty objects with twenty blocks, or drive them from a map:
variable "legacy_buckets" {
type = map(string) # name => actual bucket name
}
import {
for_each = var.legacy_buckets
to = aws_s3_bucket.legacy[each.key]
id = each.value
}
for_each support (Terraform 1.7) and the ability to target addresses inside modules (also 1.7) are what make bulk adoption practical: a generated CSV becomes a map variable, and one plan imports the whole estate with a diff you approve before anything is written. Every imported object lands under its final address in the same pass, so there is no second migration to schedule afterward.
Two companion behaviors complete the picture. First, a one-shot migration helper: terraform plan -generate-config-out=generated.tf emits draft resource blocks for the objects an import block is importing, so the post-import authoring work starts from generated code instead of a blank file. Treat the output as a draft to edit — it captures attributes but not your team's naming conventions or derivations — and never commit it without review. Second, lifecycle hygiene: once the objects are imported and the configuration merged, delete the import blocks. They are a migration record, not a permanent declaration — leaving them means every future plan re-runs the import path against objects that are now managed, which Terraform tolerates but which clutters plans and confuses readers about whether adoption ever completed.
The decision rule across the whole topic: use terraform import for one-off, interactive adoption where you are exploring an unknown object — you are not yet sure what the address should be, and the shell is the cheapest place to find out. Use import blocks for anything that must be repeatable, reviewable, or executed by CI: the same adoption on a colleague's machine must produce the same state, and a block is the only form that guarantees it. And when the object already has a block but moved address, neither form applies — that is relocation, and the declarative form of that is the moved Block Declarations topic next. One more hybrid worth knowing: a moved block and an import block can appear in the same configuration, letting a single plan both adopt an object and file it under its final address — the migration pattern for renaming something as you bring it under management.
Quiz
1. You run `terraform import 'aws_instance.web' 'i-0abc123'` and the import reports success, but nobody writes a resource block. What does the next terraform plan propose?
2. After importing an instance, your first plan shows `instance_type: "t3.small" → "t3.micro"`. What is the correct response?
3. What does an `import` block do, and when does the state write happen?
4. You must adopt 30 existing buckets declared in a CSV of name,id pairs. Which approach best fits?
Flashcards
Question
What does `terraform import` do — and what does it refuse to do?
Click to reveal answer
Answer
It reads a real object by provider ID, refreshes it, and writes a state entry at the address you name — module-prefixed addresses included. It never generates configuration, so the matching resource block is your job; without it, the next plan proposes destroying the imported object.
Question
What is the safe end-to-end import sequence?
Click to reveal answer
Answer
1) Determine the object's real ID (console/cloud CLI). 2) `terraform import <address> <id>`. 3) Author the matching resource block using `state show` output as the specification. 4) Plan and reconcile every diff by class (authoring gap, real drift, computed attribute) before applying. Steps 2–3 belong in one change set.
Question
How do you classify the three kinds of post-import plan diffs?
Click to reveal answer
Answer
Authoring gap (block disagrees with the object — fix the block, do not apply), real drift (object changed out-of-band — decide, then reconcile), and computed attribute (ARN/IDs/IPs shown as known-after-apply — normal, ignore). Never apply until the first class is empty.
Question
import block vs terraform import command: what does each offer?
Click to reveal answer
Answer
import blocks (1.5+) declare adoption in configuration: executed during plan, persisted on apply, reviewed in git, reproducible in CI; 1.7 added for_each and module-address targets for bulk adoption, plus `plan -generate-config-out` to draft the resource blocks. The command is imperative, per-object, and lives only in shell history.
Question
When should import blocks be deleted, and why?
Click to reveal answer
Answer
After adoption completes and the configuration is merged — they are a migration record, not a permanent declaration. Left in place, every future plan re-runs the import path against now-managed objects: tolerated by Terraform, but it clutters plan output and obscures whether adoption actually finished.
Revision Notes
Key Takeaways
- 1. terraform import attaches a real object to a state address via its provider ID and refreshes it — but generates no configuration, ever
- 2. A resource block with no matching config is an orphan: importing without authoring converts adoption into a pending destroy
- 3. state show output is the specification for the block; the post-import plan is a reconciliation checklist you classify diff-by-diff before applying
- 4. import blocks move adoption into configuration — executed at plan, persisted at apply, reviewable in git, and with for_each/module support they scale to whole estates
- 5. Import targets objects NOT in state; addresses that merely moved are relocation (state mv / moved blocks), and the wrong tool here plans destruction
Interview Tips
- • "What is the difference between terraform import and a moved block?" — import attaches an object that is NOT in state (adoption); moved relocates an address that IS in state (refactor). One closes a gap to reality, the other tracks a code change
- • "You imported a resource and the plan wants to destroy it. Why?" — import wrote state but no resource block declares the address, so the plan sees an orphan; the fix is authoring the block from `state show`, in the same change set as the import
- • "How do you import 50 resources?" — for_each-driven import blocks over a map variable (1.7+), one reviewed plan, apply, then delete the blocks; mention -generate-config-out as a drafting aid that still needs human review
- • "Import plan shows a diff. Walk me through your triage" — classify each line: authoring gap (fix the block, never apply), real drift (make a decision, then reconcile), computed attribute (ignore); applying before triage risks resizing production
Cheat Sheet
Importing Existing Resources
Command form (imperative, per object):
terraform import 'aws_instance.web' 'i-0abc123def456' # address, provider ID
terraform import 'module.net.aws_vpc.main' 'vpc-0abc' # module addresses work
terraform show 'aws_instance.web' # your spec for the block
Block form (declarative, Terraform 1.5+; for_each + modules 1.7+):
import {
to = aws_s3_bucket.legacy[each.key]
id = each.value
for_each = var.legacy_buckets
}
terraform plan # import executes here
terraform apply # state write persists here
terraform plan -generate-config-out=generated.tf # draft blocks — review before commit
Hard rules:
- Import writes state, never config — no block ⇒ next plan DESTROYS the object
- Post-import plan diff classes: authoring gap (fix block) · real drift (decide) · computed (ignore)
- Import one object per address; bulk = for_each import blocks
- Delete import blocks after adoption; they are migration records, not permanent config