Bring cloud resources that already exist under Terraform management without recreating them, using import blocks or the classic import command.
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:
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.
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.
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:
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:
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:
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:
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:
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.
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.
Real code is not flat. If the bucket lives inside a module, the to address includes the module path:
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:
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.
You will still see the old approach in scripts and older docs, and it works fine when you already have the resource block written:
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.
Import's opposite is terraform state rm. It drops a resource from state without touching the real infrastructure:
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.
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.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A practical look at when to reach for count, when for_each saves you, and the specific errors each meta-argument tends to throw.
A practical guide to resolving Terraform provider version conflicts and lock file checksum errors across modules, developers, and CI pipelines.
Explore more articles in this category
Terraform's errors are scarier than the fixes. This is the map to the ones everyone hits: what each message means, the safe way out, and how to avoid losing state.
A practical guide to renaming resources, migrating backends, and splitting or merging Terraform state without destroying and recreating infrastructure.
A practical guide to resolving Terraform provider version conflicts and lock file checksum errors across modules, developers, and CI pipelines.