Import Existing Infrastructure into Terraform Without Hand-Writing HCL

Photo by RukshanV via Wikimedia Commons (CC BY-SA 4.0)
The terraform import CLI command is imperative: it writes a resource into state immediately and generates no configuration, leaving no trace in your code. An import block is declarative — you commit it to a .tf file, it appears in terraform plan, goes through code review, and nothing changes in state until terraform apply. Import blocks were added in Terraform 1.5.
When you run terraform plan -generate-config-out=file.tf, Terraform reads each existing object referenced by an import block that has no matching resource, then writes HCL with its best guess for every argument to that file. It gives you a scaffold of the resource configuration instead of a blank file, so you refine a draft rather than hand-writing everything. It was introduced in Terraform 1.5 and is still experimental.
No. Config generation cannot be used with import blocks that use for_each, or with resources that use for_each or count, and it does not work for resources inside modules that do not already exist. Terraform 1.7 added for_each support to the import block itself, but config generation for those instances is still not covered. Generate against a plain top-level resource address first, then refactor the cleaned HCL into your collection or module by hand.
The most common cause is pointing the flag at a file that already exists — Terraform refuses to overwrite it and errors, so delete the generated file between runs. It also fails with the remote backend or HCP Terraform because the flag must write to the local filesystem. Finally, generated config can include conflicting arguments (for example ipv6_address_count and ipv6_addresses together) that fail validation until you edit them.
No. Import blocks are single-use scaffolding. Once terraform apply commits the object to state, the block does nothing on future plans, so you can safely delete it in a follow-up pull request. Keeping them in a dedicated file like imports.tf makes that cleanup a one-file change.

Photo by RukshanV via Wikimedia Commons (CC BY-SA 4.0)
Key Takeaway
Terraform 1.5 added import blocks: declarative import you write in your config, see in terraform plan, and review like any code change. Pair them with the -generate-config-out flag and Terraform writes best-guess HCL for each imported resource, so you refine drafts instead of hand-authoring every argument from scratch.
Almost every infrastructure job I take on starts with the same problem: something already exists. A load balancer clicked together in a console two years ago, a database created during an incident, a DNS zone nobody remembers touching. Terraform is happiest with greenfield, but real life is brownfield. The question is never whether to bring existing infrastructure under Terraform — it is how much pain that migration costs.
For years the answer was: a lot. The old terraform import CLI command only wrote a resource into state — it never generated the configuration to match. You imported the resource, then hand-wrote the HCL, then ran plan and prayed the diff came back empty. On anything larger than a handful of resources that loop was exhausting and error-prone. Since Terraform 1.5, import blocks plus the -generate-config-out flag change the shape of that work entirely.
The classic CLI import is imperative: you run a command, it mutates state immediately, and it leaves no trace in your configuration or your pull request. Two problems fall out of that. First, there is nothing to review — a teammate cannot see in a diff what you imported. Second, you still have to write every argument of the resource yourself, matching whatever the cloud provider actually has, or your next plan shows a destructive change.
# The old imperative way — state changes instantly, no config generated
terraform import aws_instance.web i-0abc123def456
# Now you STILL have to hand-write this block to match reality:
resource "aws_instance" "web" {
# ...guess every argument, or the next plan wants to destroy it
}An import block is a normal Terraform block you commit to a .tf file. It names the resource address you want the imported object to live at (the to argument) and the provider-specific ID of the existing object (the id argument). Because it lives in your configuration, it shows up in terraform plan, it goes through code review, and nothing touches state until terraform apply. That is the whole shift: import becomes declarative and reviewable instead of a one-off console command.
# imports.tf — declarative, reviewable, applied on `terraform apply`
import {
to = aws_instance.web
id = "i-0abc123def456"
}
import {
to = aws_s3_bucket.assets
id = "my-company-assets"
}Keep import blocks in a dedicated file like imports.tf. They are single-use scaffolding — once the apply lands and the resource is in state, the block does nothing on future plans and you can delete it in a follow-up PR. Grouping them makes that cleanup a one-file change.
The import block on its own still assumes a matching resource block exists. The magic is the -generate-config-out flag, introduced alongside import blocks in Terraform 1.5 and still labelled experimental. Point terraform plan at a new file path and Terraform reads the live object for every import block that has no matching resource, then writes HCL with its best guess for each argument. You get a scaffold, not a finished module — but a scaffold beats a blank file.
# Point at a NEW file path — Terraform errors if the file already exists
terraform plan -generate-config-out="generated.tf"
# Terraform reads each imported object and writes something like:
resource "aws_instance" "web" {
ami = "ami-0abc1234"
instance_type = "t3.medium"
availability_zone = "ap-southeast-1a"
vpc_security_group_ids = ["sg-0123456789"]
subnet_id = "subnet-0abc123"
# ...every discoverable argument, filled in for you
}Do not commit the output of -generate-config-out untouched. Terraform errs toward completeness, so it emits read-only attributes, computed values, and defaults that clutter the file or actively break the plan. The docs call out a concrete example: it can generate both ipv6_address_count and ipv6_addresses on an instance even though those two arguments conflict, producing HCL that will not validate. Treat the file as a first draft you clean up.
-generate-config-out has real limits. It cannot generate config for import blocks that use for_each, or for resources using for_each or count, and it does not work with the remote backend or HCP Terraform because it must write to the local filesystem. Terraform 1.7 added for_each support to the import block itself, but config generation for those instances still is not covered — generate against a plain top-level address first, then refactor into your collection by hand.
On a recent migration I had a couple of dozen objects to bring in across a single provider. The pattern that worked: enumerate the real IDs with the provider CLI, template one import block per ID into imports.tf, then run a single generate pass. You review the generated file in one sitting, clean it, and land it as one reviewable PR — far better than a shell loop of terraform import commands nobody can audit after the fact.
# Enumerate real IDs, then template import blocks (AWS S3 buckets example)
aws s3api list-buckets --query 'Buckets[].Name' --output text \
| tr '\t' '\n' \
| while read -r b; do
printf 'import {\n to = aws_s3_bucket.%s\n id = "%s"\n}\n\n' \
"$(echo "$b" | tr '.-' '__')" "$b"
done > imports.tf
terraform plan -generate-config-out=generated.tf| Aspect | terraform import (CLI) | import block + generate-config-out |
|---|---|---|
| Style | Imperative, runs immediately | Declarative, applied on apply |
| Reviewable in a PR | No — leaves no config trace | Yes — shows up in plan and diff |
| Generates config | Never — state only | Yes — best-guess HCL draft |
| Bulk import | One command per resource | Many blocks, one plan pass |
| for_each / count | Manual, per instance | Block supports for_each (1.7+); gen does not |
| Remote backend / HCP | Works | generate-config-out needs local FS |
The real win is not saved keystrokes — it is auditability. Import becomes a pull request your team can review, approve, and roll back, with the generated config as a starting point. That single change moved importing brownfield infrastructure from a scary console chore to a normal, boring part of my IaC workflow.