Skip to content
Infrastructure as Code iac state 4 min read

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.

Aspectimport block (1.5+)terraform import CLI
StyleDeclarative, lives in configImperative, one-off command
PlannableYes — shows up in terraform planNo — mutates state immediately
Config generationYes, via -generate-config-outNo, you write config by hand
Reviewable in PRYesNo
Best forMost workflows, especially CI/teamsQuick 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: import blocks 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 plan again after editing it and confirm the plan shows 0 to change before 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 import blocks over the CLI command — they are reviewable, plannable, and safe to run in CI.
  • Always run terraform plan after importing and keep iterating on config until the plan shows 0 to change.
  • Use -generate-config-out for 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.
Last updated June 14, 2026
Was this helpful?