How to Import Existing Resources into Terraform and OpenTofu (2026)

Oct 20, 2026 · 13 min · Ajay Kumar

This guide is for engineers who inherited infrastructure built by hand, by ClickOps or by a tool you are retiring, and now need it under Terraform or OpenTofu without recreating anything. By the end you will have the resources in state, clean HCL in version control, and a plan that proves nothing will change. Commands target Terraform 1.16.6 and OpenTofu 1.13.1, the current releases in October 2026, with the AWS provider for examples; the import mechanics are identical for other providers. You need read access to the account and an empty or existing root module with a remote state backend already configured.

Before you start: pick the right import method

There are four ways in, and they are not interchangeable.

Method Available since Good for Avoid when
terraform import CLI forever one-off fixes anything you want reviewed in a PR
import block + plan Terraform 1.5 (notes) normal work, reviewable never, this is the default
import block with for_each Terraform 1.7 (notes), OpenTofu many similar resources you also need generated config
list block + terraform query Terraform 1.14 (notes) discovering what exists OpenTofu (not supported yet)

The import block is the one to standardise on. It turns adoption into a normal plan that a reviewer can read, it runs in CI, and a mistake shows up as "must be replaced" before anything happens. If you are still deciding whether Terraform is the right home for this infrastructure at all, read the sibling post, Crossplane vs Terraform vs Pulumi in 2026, first.

Import workflow: nothing is applied until the plan shows zero changes 1. Discover terraform query or console 2. Declare import blocks: id or identity 3. Generate plan -generate-config-out 4. Clean drop computed attrs, add vars 5. Verify N to import, 0 to change 6. Apply then remove or keep blocks changes or replace? back to 4
The import loop used in this guide. Steps 1 and 3 are optional when you already know the IDs and are writing the HCL by hand.

Step 1: Pin versions and configure the provider

Import blocks only work against a provider you have configured, and generated configuration depends on the provider's schema, so pin both. Different provider versions generate different HCL.

# versions.tf
terraform {
  required_version = ">= 1.14.0"   # 1.14+ for terraform query; 1.5+ is enough for import blocks
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.69"          # v6.69.1 was current on 15 Oct 2026
    }
  }
  backend "s3" {
    bucket       = "acme-tfstate"
    key          = "imported/network.tfstate"
    region       = "eu-west-1"
    use_lockfile = true            # S3-native locking, no DynamoDB table needed
  }
}

provider "aws" {
  region = "eu-west-1"
}
terraform init
# OpenTofu: tofu init

Use credentials with read access for discovery and generation. You only need write access at apply time, and an import itself does not modify the remote object. If your state backend is new, the state locking guide covers what to do when a lock gets stuck mid-import.

Step 2: Write an import block with id or identity

An import block has two jobs: to is the address in your configuration, and either id or identity names the real object. They are mutually exclusive (import block reference). id is the legacy string format each resource documents in its Import section. identity is a structured map, available since Terraform 1.12 for resources whose provider publishes an identity schema; OpenTofu's docs describe the same pair.

# imports.tf
# Legacy string ID, works on Terraform 1.5+ and OpenTofu
import {
  to = aws_s3_bucket.logs
  id = "acme-prod-logs"
}

# Structured identity, Terraform 1.12+ (aws_s3_bucket: bucket required,
# account_id and region optional, per the AWS provider docs)
import {
  to = aws_s3_bucket.assets
  identity = {
    bucket = "acme-prod-assets"
    region = "eu-west-1"
  }
}

Find the ID format in the resource's documentation under Import. It is not always the name: security group rules, IAM policy attachments and Route 53 records use composite IDs, and guessing wrong is the most common import failure.

Step 3: Generate configuration with -generate-config-out

If you do not want to hand-write the resource blocks, let Terraform draft them. Leave the resource block out, keep the import block, and run plan with an output path that does not exist yet:

terraform plan -generate-config-out=generated.tf
# OpenTofu: tofu plan -generate-config-out=generated.tf

I ran this against the random provider (v3.7.2) with Terraform 1.12 in a scratch directory to show the real output shape:

  # random_id.server will be imported
  # (config will be generated)
    resource "random_id" "server" {
        b64_std     = "p+9hUg=="
        b64_url     = "p-9hUg"
        byte_length = 4
        ...
    }

Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.

Warning: Config generation is experimental

Terraform has generated configuration and written it to generated.tf.

And the generated file:

# __generated__ by Terraform
# Please review these resources and move them into your main configuration files.

# __generated__ by Terraform from "p-9hUg"
resource "random_id" "server" {
  byte_length = 4
  keepers     = null
  prefix      = null
}

Note the warning: HashiCorp still labels config generation experimental, introduced in 1.5, and the format may change. Treat the file as a draft. Two rules from the docs bite people: the output path must not already exist, and for complex schemas Terraform can write invalid HCL with a "conflicts with" error, which you fix by deleting one of the two conflicting arguments and planning again. Generation only runs for import targets that have no resource block yet.

Step 4: Discover resources in bulk with terraform query

On Terraform 1.14 and later you no longer need a separate reverse-engineering tool to find what exists. Put list blocks in a file ending in .tfquery.hcl and run terraform query (bulk import docs). The AWS provider documents 257 list resources in its repository as of this week, including buckets, instances, IAM roles, VPCs and security groups.

# discover.tfquery.hcl  (Terraform 1.14+, AWS provider 6.x)
list "aws_instance" "stopped" {
  provider = aws
  limit    = 200                  # default is 100 per list block
  config {
    filter {
      name   = "instance-state-name"
      values = ["stopped"]
    }
  }
}

list "aws_s3_bucket" "all" {
  provider = aws
}
terraform validate -query            # offline check of the query file (1.14+)
terraform query                      # print what was found
terraform query -json > found.json   # machine-readable, for scripting
terraform query -generate-config-out=generated.tf   # import + resource blocks

The generated file contains both import blocks (using identities) and resource blocks. A few caveats from the docs: the aws_instance list resource excludes Auto Scaling group members and terminated instances by default (include_auto_scaled changes that), setting include_resource = true fetches full attributes but is slower, and rerunning with an existing output file fails. OpenTofu does not have query or list blocks yet; there is an open issue for it. On OpenTofu, discover with your cloud's CLI and feed the IDs into Step 5.

Step 5: Bulk import with for_each

When you have fifty buckets that should share one resource block, use for_each on the import block (Terraform 1.7+ and OpenTofu). The resource block must also use for_each with the same keys.

# buckets.tf
locals {
  # key = address suffix, value = real bucket name
  buckets = {
    logs    = "acme-prod-logs"
    assets  = "acme-prod-assets"
    backups = "acme-prod-backups"
  }
}

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

resource "aws_s3_bucket" "this" {
  for_each = local.buckets
  bucket   = each.value
}

You can build the map from the found.json of Step 4 or from aws s3api list-buckets --query 'Buckets[].Name'. The import target may also be inside a module, for example to = module.group[each.value.group].aws_s3_bucket.this[each.value.key], and since Terraform 1.16 the import block itself can live inside a child module.

The catch: you cannot combine for_each imports with config generation. OpenTofu's docs say so directly, and Terraform refuses too. With no resource block present, my test produced:

Error: Invalid import 'to' expression

  on main.tf line 9, in import:
   9:   to = random_id.server[each.key]

The target resource does not use for_each.

So the practical order for big estates is: generate config for one representative instance, turn it into a for_each resource by hand, then import the rest with a for_each import block.

Step 6: Clean up the generated configuration

Generated HCL describes every attribute the provider returned, which is not the same as what you want to own. Clean it before anyone reviews it:

  1. Delete null and default values. Lines like keepers = null add noise and make future diffs harder to read.
  2. Delete read-only and computed attributes if they slipped in (ARNs, IDs, timestamps). The plan will error or show perpetual diffs if they stay.
  3. Split deprecated inline blocks. The AWS provider moved bucket versioning, encryption, lifecycle and ACLs into separate resources (aws_s3_bucket_versioning and friends). Import those as their own resources rather than keeping legacy inline arguments.
  4. Replace literals with references. Hard-coded subnet IDs, KMS key ARNs and account IDs should become references to other resources or variables, otherwise you have imported a snapshot rather than a model.
  5. Move resources into modules and files that match your repo layout, using moved blocks if they were already in state under another address.
  6. Add guards. For anything stateful, add lifecycle { prevent_destroy = true } before the first apply.

Run terraform fmt and your static checks afterwards; the Terraform testing guide covers tflint and Checkov in CI.

Step 7: Prove a no-op plan, apply, and remove the import blocks

The definition of done is a plan that only imports. Anything else means your HCL disagrees with reality, and applying it would change production.

terraform plan -out=import.tfplan
# Want:  Plan: 12 to import, 0 to add, 0 to change, 0 to destroy.

Here is what failure looks like. I changed byte_length from 4 to 8 in my test resource after the import blocks were in place:

  # random_id.server["a"] must be replaced
      ~ byte_length = 4 -> 8 # forces replacement
  # random_id.server["b"] must be replaced
      ~ byte_length = 4 -> 8 # forces replacement
Plan: 2 to import, 2 to add, 0 to change, 2 to destroy.

That plan would import two objects and immediately destroy and recreate them. With a real database, that is an outage. Fix the HCL until the add, change and destroy counts are zero, then apply the saved plan and confirm with a detailed exit code:

terraform apply import.tfplan
# Apply complete! Resources: 12 imported, 0 added, 0 changed, 0 destroyed.

terraform plan -detailed-exitcode
echo $?   # 0 = no changes, 1 = error, 2 = changes pending

My scratch run returned No changes. Your infrastructure matches the configuration. and exit code 0. After that, the import blocks are inert: you can delete them, or keep them as a record of where resources came from, as the docs suggest. Keep that -detailed-exitcode plan as a nightly CI job and you have drift detection for free.

What about terraformer, former2 and other tools?

The older reverse-Terraform tools have mostly faded. Google's terraformer was archived on 16 March 2026; its README says it is deprecated and will get no security patches. Terracognita has not cut a release since v0.8.4 in May 2023. Former2 is still receiving commits (July 2026) but its own badge puts Terraform resource coverage at 49 percent, and it targets AWS only. Their output also predates import blocks, so you end up translating it anyway. In 2026 I would use terraform query on Terraform, cloud CLIs plus for_each imports on OpenTofu, and treat any third-party generator as a discovery aid rather than a source of HCL. If you are moving to Pulumi instead, pulumi import --from terraform ./terraform.tfstate adopts an existing state file (Pulumi docs).

Troubleshooting

Symptom Likely cause Fix
Configuration for import target does not exist Import block (or terraform import) points at an address with no resource block Add the resource block, or remove it and use -generate-config-out
The target resource does not use for_each for_each import with a missing or non-for_each resource block Add for_each to the resource with matching keys; generation does not support for_each
Plan says must be replaced after import A ForceNew argument in your HCL differs from reality Match the real value (see the # forces replacement line) before applying
Plan shows in-place updates after import Defaults or generated values differ from the API's Copy the real value or delete the argument; repeat until 0 to change
conflicts with error after generation Provider schema has mutually exclusive arguments Delete one of the two arguments in generated.tf and re-plan
Error that the output file already exists -generate-config-out refuses to overwrite Delete or rename generated.tf first
Cannot import non-existent remote object Wrong ID format, wrong region or wrong account Check the resource's Import docs, provider region and credentials
terraform query returns nothing Provider version without that list resource, wrong region, or default filters Upgrade the provider, set region, check flags like include_auto_scaled
tofu query is not a command OpenTofu does not support list resources yet Discover with the cloud CLI and import with for_each
Same object imported into two addresses Two import blocks or two states own it Remove one with terraform state rm or a removed block; one owner per object

FAQ

Should I use terraform import or an import block?

Use the import block. It goes through plan, so it can be reviewed in a pull request and run in CI, and a wrong configuration shows up as a diff before anything changes. The terraform import CLI still works but writes straight to state with no preview.

Can Terraform generate configuration for existing resources?

Yes. terraform plan -generate-config-out=FILE drafts resource blocks for import targets that have no configuration, and on Terraform 1.14+ terraform query -generate-config-out=FILE also writes the import blocks for everything a list block finds. Both are drafts that need cleaning, and generation is still marked experimental.

Does OpenTofu support import blocks?

Yes, including id, identity, for_each and tofu plan -generate-config-out. It does not yet support list blocks or a query command, so bulk discovery happens outside OpenTofu.

Can I remove import blocks after applying?

Yes. Once the apply succeeds the objects are in state and the import blocks do nothing on later plans. Many teams delete them in the next PR; keeping them is harmless and documents provenance.

In production the first thing I check is the plan summary line, before any of the diff: if it says anything other than "N to import, 0 to add, 0 to change, 0 to destroy", the PR does not merge. Second, I import into a fresh, small state per area (network, data, compute) rather than one giant root module, because a bad import is far easier to back out of a small state. The official references are the Terraform import block, generating configuration and bulk import pages, and OpenTofu's import docs and generating configuration page.

Related: Infrastructure as Code in 2026: Two Forks at 1.16 and 1.13, a $6.4B Owner, 912 Public State Files, and One Agent That Ran terraform destroy, Terraform State in 2026 and Crossplane vs Terraform vs Pulumi in 2026.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related