# How to Import Existing Resources into Terraform

URL: https://www.devopsness.com/blog/terraform-import-existing-resources
Author: Kiril Urbonas
Published: 2026-08-01
Updated: 2026-10-31
Categories: DevOps
Tags: Terraform, Iac, Import, Migration, Troubleshooting, DevOps

Summary: Bring cloud resources that already exist under Terraform management without recreating them, one at a time or hundreds at once, using import blocks or the classic import command.

---
# How to Import Existing Resources into Terraform

Import an existing resource by adding an `import {}` block that maps a resource address to the cloud ID, then running `terraform plan -generate-config-out=generated.tf`. Terraform drafts the HCL, the plan shows `1 to import`, and nothing is created or destroyed. Clean the generated file, apply, and keep tuning until `plan` reports no changes.

> [!VERIFIED]
> Terraform 1.17.1 (current release), with import blocks from the 1.5 changelog and import `for_each` and `removed` blocks from the 1.7 changelog, against the developer.hashicorp.com import docs. Checked October 2026.

You end up here when a resource exists in the cloud but not in state. Someone clicked it together during an incident, or it predates your Terraform adoption. Write matching config and run `apply`, and you get:

```
Error: creating S3 Bucket: BucketAlreadyOwnedByYou
```

State has no record of the bucket, so Terraform tries to create it. Don't delete and recreate. Import tells Terraform "this real resource maps to this address in my config," and from then on Terraform manages it like anything else.

## Two ways to import

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

**Modern:** the `import {}` block (added in Terraform 1.5) plus `terraform plan -generate-config-out=`. Terraform reads the live resource and generates the HCL for you. This is the recommended path, so we start there.

## The modern path: import blocks

Say an S3 bucket named `acme-prod-assets` exists in AWS but not in your config. First, find its import ID. For S3 it's the bucket name (current AWS provider versions also accept an `identity` object instead of `id`); for an EC2 instance it's the instance ID like `i-0abc123`; for a security group, `sg-...`. Confirm it with the CLI:

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

Add an `import` block to any `.tf` file, pointing a resource address at that ID:

```hcl
# imports.tf
import {
  to = aws_s3_bucket.assets
  id = "acme-prod-assets"
}
```

You haven't written the `resource "aws_s3_bucket" "assets"` block yet. Let Terraform generate it:

```bash
$ terraform plan -generate-config-out=generated.tf
```

Terraform reads the live bucket, writes a `generated.tf` with a full resource block, and prints a plan showing `1 to import`. Read the file carefully, because generated config is working HCL, not good HCL:

- It includes read-only and default attributes you usually want to delete; they clutter the file and sometimes conflict on apply.
- It hard-codes everything. A generated security group references `vpc-0abc123` as a string instead of `aws_vpc.main.id`, an account ID or KMS key ARN appears as a literal. Until you wire those into references, Terraform doesn't know the resources depend on each other and can't order operations correctly.
- It captures drift as if it were intent. If someone manually set an odd `apply_immediately` or left a default tag off, the generated config bakes that in. Normalize tags, naming and lifecycle rules against your conventions as you go.
- It may leave gaps Terraform couldn't infer, marked with comments.

Move the cleaned block into your real files (for example `s3.tf`), delete `generated.tf`, and run `terraform apply`. You want `1 imported, 0 added, 0 changed, 0 destroyed`.

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

Import brings the resource into state exactly as it exists. If your HCL says one thing and the cloud says another, the next plan reconciles them, usually by changing the live resource to match your config. If the real bucket has versioning enabled and your config omits it, Terraform may plan to alter it.

The rule: **keep tuning config and re-running `plan` until it reports no changes.**

```bash
$ terraform plan
# No changes. Your infrastructure matches the configuration.
```

That line means state, config and reality agree. Any remaining diff is a change Terraform would make on the next apply.

## Importing into modules, for_each and count

If the bucket lives inside a module, the `to` address includes the module path:

```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 by key, like `aws_s3_bucket.assets["prod"]`. Since Terraform 1.7 the import block itself also accepts `for_each`, which saves writing one block per resource:

```hcl
import {
  for_each = local.buckets
  to       = aws_s3_bucket.this[each.key]
  id       = each.value
}
```

The `to` address must exactly match what Terraform would print in a plan. When in doubt, run `terraform plan` and copy the address.

## Importing at scale: 200 resources, no state file

Picture the common inheritance: an AWS account with a one-line handover doc and roughly 200 resources built by hand over several years, covering VPCs, security groups, RDS instances and a tangle of IAM roles. No Terraform, no CloudFormation. Rebuilding is off the table because these are live production resources with data and traffic. Hand-writing HCL that matches years of console tweaks for the classic command is days of work and a real chance of a plan that proposes deleting the production database. Generated config turns it into review and clean-up.

What keeps it manageable:

**Discover from the API, not the console or a runbook.** Generate import blocks programmatically so the list is complete:

```bash
$ aws ec2 describe-security-groups \
    --query 'SecurityGroups[].[GroupId,GroupName]' \
    --output text | while read id name; do
      printf 'import {\n  to = aws_security_group.%s\n  id = "%s"\n}\n' \
        "$(echo "$name" | tr -c 'a-zA-Z0-9' '_')" "$id"
    done > sg_imports.tf
```

For a first pass across everything, `terraformer` can bulk-generate state and config, but its output is messy; we treat it as a discovery tool, not the final artifact.

**Batch by dependency order and blast radius.** Not 200 import blocks in one giant plan. Networking first (VPCs, subnets, route tables), because everything depends on it. Then security groups. Stateful resources (RDS, ElastiCache) last, because that's where a mistaken `-replace` is catastrophic.

**Read every plan line by line.** Import populates state but not the `depends_on` or data-source relationships you'd normally write. The classic trap: an RDS instance imports cleanly with a green plan, but its subnet group was imported separately and the dependency was never expressed. A later refactor reorders things, and Terraform proposes a subnet group change that forces the database to be recreated. You catch that in plan review or you catch it in an outage. During an import project, the plan output is the safety net, not a formality.

## The classic command, and the way back out

The old approach still shows up in scripts. It works when the resource block is already written:

```bash
$ terraform import aws_s3_bucket.assets acme-prod-assets
```

It mutates state immediately: no plan step, no generated config, no undo beyond editing state. Because you write the HCL by hand, the config-must-match gotcha bites harder.

Import's opposite is `terraform state rm aws_s3_bucket.assets`. Terraform forgets the resource; the resource keeps running. That's what you want when handing a resource to another state file, splitting a monolith, or retrying an import that went wrong. Pair it with a fresh `import` block to move a resource between configurations without downtime. The declarative `removed {}` block (Terraform 1.7+) does the same through plan review, which is safer in team workflows. For renames and splits that stay inside Terraform, [moving Terraform state safely](/blog/terraform-state-migration) covers `moved` blocks and `state mv`.

## Questions people ask

### How do I import an existing resource into Terraform?

Add an `import` block with the resource address in `to` and the cloud ID in `id`, run `terraform plan -generate-config-out=generated.tf`, then move the cleaned HCL into your real files and apply. Keep adjusting the config until `terraform plan` reports no changes, which proves state, config and the live resource agree.

### Can I import many resources at once in Terraform?

Yes. Generate import blocks from your cloud provider's API with a script, or put `for_each` on a single import block, which Terraform supports from 1.7. Batch the work by dependency order: networking first, security groups next, stateful resources such as RDS last. Review each plan before applying, because a missing dependency can turn into a proposed replacement.

### What's the difference between terraform import and an import block?

The `terraform import` command writes state immediately, generates no config, and has no plan step, so you must hand-write a matching resource block first. An import block, available since Terraform 1.5, goes through `plan`, can draft HCL with `-generate-config-out`, and sits in a pull request for review before anything touches state.

## The decision, concretely

- **One resource and the HCL already written?** `terraform import` is fine for a quick one-off.
- **Anything else?** Use `import` blocks with `-generate-config-out` so the change is planned and reviewable.
- **Dozens or hundreds of resources?** Generate the blocks from the API, batch network first and stateful last, and wire hard-coded IDs into references.
- **Import went sideways?** `terraform state rm` or a `removed {}` block, then re-import cleanly.

## The call we'd make

Reach for `import` blocks with `-generate-config-out` every time. The generated HCL saves transcription, and the whole flow fits in a pull request. Treat generated config as a draft: strip the defaults, wire in references and variables, and don't apply until `plan` reports no changes. At scale, read every plan during the migration like the database depends on it, because sometimes it does. For the wider set of state and apply failures that send people here, see our [Terraform error troubleshooting guide](/blog/terraform-error-troubleshooting-guide).

## Sources

- [Terraform docs: Import existing resources](https://developer.hashicorp.com/terraform/language/import)
- [Terraform docs: import block reference (for_each)](https://developer.hashicorp.com/terraform/language/block/import)
- [Terraform docs: terraform import command](https://developer.hashicorp.com/terraform/cli/commands/import)
- [Terraform v1.5 changelog (import blocks, -generate-config-out)](https://github.com/hashicorp/terraform/blob/v1.5/CHANGELOG.md)

