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 old way and why it hurt
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
}Import blocks: config-driven import
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.
-generate-config-out: stop hand-writing HCL
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
}- Write import blocks for the resources you want, with the correct to address and id.
- Run terraform plan -generate-config-out=generated.tf against a fresh file path.
- Read the generated HCL, delete read-only and default arguments, and refactor names.
- Run terraform plan again until the diff is empty — no create, no destroy.
- terraform apply to commit the objects to state, then delete the import blocks.
The generated HCL is a draft, not a deliverable
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.
- Delete arguments the provider marks read-only or computed (IDs, ARNs, timestamps).
- Remove defaults that add noise, so the config states intent, not every knob.
- Replace hard-coded IDs with references to other resources or variables where possible.
- Rename the resource label to your convention — the generated name is just the to address.
-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.
A workflow that scales past a handful of resources
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 |
Gotchas I keep hitting
- The output path must be new. Terraform errors rather than overwriting an existing file — delete generated.tf between runs.
- Getting the id right is provider-specific. An S3 bucket uses its name, an EC2 instance its instance ID, a route53 record a composite string — check the resource's import docs.
- Config generation is still experimental; the exact HCL formatting can change between minor versions, so re-review after upgrades.
- Import to a module or a for_each instance? Generate against a top-level address first, then move the cleaned HCL into the module by hand.
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.

