Skip to content
advanced Phase 6 · State Operations, Import, and Relocation

Importing Existing Resources

Adopting pre-existing cloud objects by generating resource entries in state.

1h 5m
0 problems
Topic Progress 0%

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.

Writing the Block After Import: Reconciling the Diffs

The state entry exists; now you write the resource block that declares it. Your specification is not guesswork — terraform state show prints every attribute Terraform recorded at import:

terraform show 'aws_instance.web'

Copy the arguments you want Terraform to own (AMI, instance type, subnet, tags), reference rather than hardcode what your configuration can derive (subnet IDs from a data source, AMIs from a filter), and ignore what the API computes (ARN, private IP, IDs). Then plan, and read the diff as a reconciliation checklist with three distinct classes:

Diff class Example in plan What it means
Authoring gap instance_type: "t3.small" → "t3.micro" Your block says something the object is not — fix the block
Real drift tag changed in console last week Object diverged from intent — decide, then let apply reconcile
Computed attribute ARN shown as known after apply Normal for read-only attributes; ignore

The skill is refusing to apply until class-one diffs are gone. An instance_type mismatch looks identical in plan output whether you typo'd the block or someone resized the box, but applying it is either a no-op correction or an unwanted production change — and plan output alone will not tell you which. Read each diff, decide its class, fix the block or accept the change deliberately. Two recurring authoring traps: computed-only attributes (IDs, ARNs, IPs) written into the block produce perpetual diffs or errors because you cannot set them; and arguments your configuration derives — a subnet from for_each, a name from local.name_prefix — must be written as the derivation, not its current output, or the block freezes a snapshot instead of expressing intent.

A third trap is quietly the most common: tags. The imported object arrives with whatever tags three years of hand-management accumulated (Environment=prod, Team=platform, temporary=true, do-not-delete), and copying all of them verbatim bakes archaeology into your configuration. The plan will show each tag you drop as a diff line — that is a class-one diff too, but here the block is right and the cloud object is wrong: let apply remove the cruft, and say so in the pull request so the reviewer does not assume you missed it. The same logic applies to obsolete security-group rules and stale boot scripts: import records reality, your configuration records intent, and the first reconciling plan is where the two are negotiated, line by line, on purpose.

For bulk estates, import has an ergonomic ceiling worth knowing. One command per object means importing two hundred instances is two hundred invocations — historically scripted with a while read loop over a CSV of address,id pairs. That ceiling is precisely what the newer declarative form removes, and it is the natural next step once the manual flow is second nature. Until then, the manual loop earns its keep as a teaching tool: run it once by hand on a single object, feel every step, and the failures you meet — wrong ID, wrong region, a typo'd address — are the same ones the scripted version would bury in a hundred lines of CI log.

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?

Question 1 options

2. After importing an instance, your first plan shows `instance_type: "t3.small" → "t3.micro"`. What is the correct response?

Question 2 options

3. What does an `import` block do, and when does the state write happen?

Question 3 options

4. You must adopt 30 existing buckets declared in a CSV of name,id pairs. Which approach best fits?

Question 4 options

Flashcards

Question

What does `terraform import` do — and what does it refuse to do?

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?

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?

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?

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?

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

Continue Learning