How to Import Existing Resources into Terraform

Sooner or later every Terraform user meets a resource that already exists in the cloud but not in state. Maybe someone clicked it together in the console during an incident. Maybe it predates your Terraform adoption. Either way, the moment you write matching config and run apply, you get the dreaded:

code

Error: creating S3 Bucket: BucketAlreadyOwnedByYou

Terraform wants to create something that is already there, because state has no record of it. The fix is not to delete and recreate. The fix is import: you tell Terraform "this real resource maps to this address in my config," and from then on Terraform manages it like anything else.

This post walks the single-resource case one resource at a time. If you are staring down hundreds of unmanaged resources, read import at scale for legacy infra instead; the tactics there are different.

Two ways to import #

There are two approaches, and one of them is clearly better in 2026.

Classic: the terraform import command. It writes state but nothing else, so you have to hand-write the matching resource block yourself.

Modern: the import {} block plus terraform plan -generate-config-out=. Terraform reads the live resource and generates the HCL for you. This is the recommended path now, so we will start there.

The modern path: import blocks #

Say you have an S3 bucket named acme-prod-assets that exists in AWS but not in your config. First, find its ID. For S3 the import ID is just the bucket name; for an EC2 instance it is the instance ID like i-0abc123. The AWS CLI is the quickest way to confirm:

bash.bash

aws s3api list-buckets --query "Buckets[?Name=='acme-prod-assets']"

Now add an import block to any .tf file. It points a resource address at that real-world ID:

hcl.hcl

import {
to = aws_s3_bucket.assets
id = "acme-prod-assets"
}

You have not written the resource "aws_s3_bucket" "assets" block yet. That is fine. Ask Terraform to generate it:

bash.bash

terraform plan -generate-config-out=generated.tf

Terraform reads the live bucket and writes a generated.tf containing a full resource block with every attribute it found. It also prints a plan showing 1 to import. Open generated.tf and read it carefully, because generated config is a starting point, not a finished artifact:

It includes read-only and default attributes you usually want to delete, since they clutter the file and sometimes conflict on apply.

It hard-codes values that should be variables or references (an account ID, a KMS key ARN).

It may leave gaps Terraform could not infer, marked with comments.

Move the cleaned resource block into your real files (for example s3.tf), delete the noise, and remove the generated.tf scratch file. Then run:

bash.bash

terraform apply

You want the plan to read 1 imported, 0 added, 0 changed, 0 destroyed. Zero changes is the goal. If Terraform proposes changes, your config does not yet match reality.

The gotcha: config must match, or plan wants to change it #

This trips up almost everyone the first time. Import brings the resource into state exactly as it exists. If your HCL says one thing and the cloud says another, the next plan will try to reconcile them, usually by changing the live resource to match your config.

Example: the real bucket has versioning enabled, but your config omits it. Terraform reads the config as "no versioning specified" and, depending on the resource, may plan to alter it. The rule of thumb: keep tuning config and re-running plan until you see zero diff. Only when the plan is clean do you actually own the resource safely. Generated config gets you most of the way, but always verify against a real plan before you walk away.

Importing into modules and for_each #

Real code is not flat. If the bucket lives inside a module, the to address includes the module path:

hcl.hcl

import {
to = module.storage.aws_s3_bucket.assets
id = "acme-prod-assets"
}

For resources created with for_each or count, address the specific instance with its key:

hcl.hcl

import {
to = aws_s3_bucket.assets["prod"]
id = "acme-prod-assets"
}

The address on the left of to must exactly match the address Terraform would use in a plan. When in doubt, run terraform plan first and copy the address string Terraform prints.

The classic command, for reference #

You will still see the old approach in scripts and older docs, and it works fine when you already have the resource block written:

bash.bash

terraform import aws_s3_bucket.assets acme-prod-assets

This mutates state immediately. There is no plan step, no generated config, and no undo beyond editing state. Because you write the HCL by hand, the config-must-match gotcha bites harder here; you are guessing at attributes rather than reading them off a generated file. Use import blocks unless you have a specific reason not to.

The inverse: removing from state #

Import's opposite is terraform state rm. It drops a resource from state without touching the real infrastructure:

bash.bash

terraform state rm aws_s3_bucket.assets

Terraform forgets the bucket exists; the bucket keeps running. This is exactly what you want when you are handing a resource to another state file, splitting a monolith, or you imported the wrong thing and need a clean retry. Pair it with a fresh import block to move a resource between configurations without a single second of downtime. There is also a removed {} block for declarative removal, which is the safer choice in team workflows since it goes through plan review.

The call we'd make #

Reach for import blocks with -generate-config-out every time. The generated HCL saves you from transcribing attributes by hand, the plan step shows you exactly what will happen before anything changes, and the whole flow is reviewable in a pull request. Treat generated config as a draft: strip the defaults, wire in your variables, and do not stop until plan reports zero changes. Keep terraform import in your back pocket for quick one-offs, and remember state rm as the clean exit when an import goes sideways.

For the wider set of state and apply failures that send people here in the first place, see our Terraform error troubleshooting guide.