Importing Existing Resources
Not every resource is born from Terraform. Real environments accumulate infrastructure created by hand in a console, by a legacy script, or by another team before Terraform existed. Importing is how you adopt that “brownfield” infrastructure: you tell Terraform which real-world object maps to which resource address, Terraform records it in state, and from then on it manages the resource like any other. Crucially, import only populates state — it never modifies your live infrastructure.
Two ways to import
Terraform offers two mechanisms. The legacy terraform import CLI command has existed for years; the declarative import block, added in Terraform 1.5 (and supported by OpenTofu), is now the recommended approach because it is reviewable, planned, and reproducible.
| Aspect | import block (1.5+) | terraform import CLI |
|---|---|---|
| Style | Declarative, lives in config | Imperative, one-off command |
| Plannable | Yes — shows up in terraform plan | No — mutates state immediately |
| Config generation | Yes, via -generate-config-out | No, you write config by hand |
| Reviewable in PR | Yes | No |
| Best for | Most workflows, especially CI/teams | Quick local fixes, scripting |
Writing matching configuration
Whichever method you use, Terraform expects a resource block whose attributes describe the imported object. If the config and the real resource disagree, the next plan will propose changes — sometimes destructive ones. So always run a plan after importing and reconcile any drift before applying.
Suppose an S3 bucket named acme-prod-assets was created manually. First write a stub resource block:
resource "aws_s3_bucket" "assets" {
bucket = "acme-prod-assets"
}
Using the import block
Add an import block referencing the resource address (to) and the provider-specific identifier (id). For an S3 bucket the import ID is the bucket name:
import {
to = aws_s3_bucket.assets
id = "acme-prod-assets"
}
resource "aws_s3_bucket" "assets" {
bucket = "acme-prod-assets"
}
Now run a plan. Terraform reads the live object and compares it to your config:
terraform plan
Output:
aws_s3_bucket.assets: Preparing import... [id=acme-prod-assets]
aws_s3_bucket.assets: Refreshing state... [id=acme-prod-assets]
Terraform will perform the following actions:
# aws_s3_bucket.assets will be imported
resource "aws_s3_bucket" "assets" {
bucket = "acme-prod-assets"
id = "acme-prod-assets"
# (8 unchanged attributes hidden)
}
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
A clean plan — 1 to import with 0 to change — means your config matches reality. Apply it:
terraform apply
The resource is now in state. Once the import succeeds you can delete the import block; it has done its job and leaving it in is harmless but noise. Many teams keep import blocks in a dedicated imports.tf and remove them after the merge.
Tip:
importblocks are idempotent. If the resource is already in state, the block is silently a no-op, so a stale block won’t break a later apply. That makes it safe to leave in place until your next cleanup PR.
Generating configuration automatically
Writing config by hand for a resource with dozens of attributes is tedious and error-prone. Terraform 1.5+ can generate it for you with -generate-config-out. Provide only the import block — no resource block — and Terraform writes a matching configuration:
import {
to = aws_iam_role.deployer
id = "ci-deployer"
}
terraform plan -generate-config-out=generated.tf
Output:
aws_iam_role.deployer: Preparing import... [id=ci-deployer]
aws_iam_role.deployer: Refreshing state... [id=ci-deployer]
Terraform will perform the following actions:
# aws_iam_role.deployer will be imported
# (config will be generated)
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
Terraform has generated configuration and written it to generated.tf.
Open generated.tf, review it, and move the block into your real .tf files. Generated config is a starting point — it may include read-only computed attributes you should remove and inline policy JSON you may want to refactor into a variable or aws_iam_policy_document data source.
Warning: Generated config is best-effort. Always run
terraform planagain after editing it and confirm the plan shows0 to changebefore applying to production.
Importing with the CLI command
When you need a quick, scripted import — or are on a pre-1.5 version — use the command. You still must write the resource block first:
terraform import aws_s3_bucket.assets acme-prod-assets
For resources inside modules or count/for_each collections, address them explicitly and quote the address so your shell does not interpret the brackets:
terraform import 'module.network.aws_subnet.private["us-east-1a"]' subnet-0ab12cd34ef56789a
Unlike the import block, this command mutates state immediately with no plan step, so verify state afterward with terraform plan.
Finding the right import ID
The hardest part of importing is usually the ID format, which is provider- and resource-specific. The S3 bucket uses its name, an EC2 instance uses its instance ID (i-1234567890abcdef0), and a route table association uses a compound route-table-id/subnet-id. Always check the resource’s documentation under the “Import” heading for the exact syntax.
Best practices
- Prefer
importblocks over the CLI command — they are reviewable, plannable, and safe to run in CI. - Always run
terraform planafter importing and keep iterating on config until the plan shows0 to change. - Use
-generate-config-outfor large resources, then prune computed-only and read-only attributes from the output. - Import into a non-production workspace or a copy of state first if you are unsure about the ID or attribute mapping.
- Group import blocks in a dedicated file and remove them once merged to keep your configuration clean.
- Quote resource addresses that contain
[]to avoid shell globbing surprises. - Back up your state (or rely on a locked remote backend) before bulk imports.