Import and Query

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Terraform is easiest when it creates everything it manages. Real organizations rarely start that way. There is a storage bucket somebody created in the console three years ago, a PostgreSQL instance that predates the repository, a DNS zone owned by another team that now needs to be code. Terraform will not manage any of these until it has a binding between a real object and a resource address in a state. Creating that binding without creating or changing the object is what import means, and it is the main way to adopt existing infrastructure ("brownfield") into code.

This page describes importing as documented for Terraform 1.17 (released 2026-10-21) and OpenTofu 1.13 (latest patch 1.13.2). It covers the legacy terraform import command, the configuration-driven import block, configuration generation with terraform plan -generate-config-out, the list resource and terraform query workflow introduced in Terraform 1.14 for discovering what exists and importing it in bulk, the search and import feature of HCP Terraform, and the exporters that other tools provide. It follows State and Backends and Remote State in the reading path, because import is nothing more than a controlled way of writing a binding into state, and it leads into Refactoring: moved, removed and import, where the same blocks are used to restructure configuration rather than to adopt objects.

The examples use the Bookshelf scenario (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers bucket). Imagine that the covers bucket and the PostgreSQL instance were created by hand before Bookshelf had any Terraform. All names, identifiers and values are placeholders. Provider resource arguments, import formats and identity schemas were checked against the provider documentation for the AWS provider (6.x) and the Google provider (8.x), and every console output on this page is abbreviated and marked as illustrative, because the exact text depends on provider versions.

What import really does

An import adds a resource instance to the state, bound to an existing remote object. Nothing else changes: the object is not created, modified or replaced by the import itself. Terraform asks the provider to read the object, and the provider returns every attribute it knows. Those attributes are written to state.

Two consequences follow, and both explain most of the mistakes people make with import.

  • State is not configuration. After an import the state contains the full object, but the configuration may contain nothing. A resource block must exist for the address (or be generated, see Generating configuration with -generate-config-out), because on the next plan Terraform compares configuration to state, and a resource that is in state but not in configuration is planned for destruction. The official docs state the rule directly: define a resource block for any resource in state to prevent Terraform from destroying it.

  • Arguments that are missing from the block take their defaults. If the object has non-default settings and the resource block does not mention them, the next plan shows an update that would reset them. The docs recommend including every provider-specific argument that has a non-default value so that Terraform does not change the object on the next apply.

The third rule is the one the state documentation puts in a single sentence. Terraform expects a one-to-one mapping between remote objects and resource instances. If you import the same object into two addresses, Terraform "may behave unexpectedly". Guaranteeing the mapping is your job once you import by hand.

flowchart LR subgraph Before["Before import"] O1["Remote object: covers bucket"] C1["Configuration: resource block"] S1["State: no binding"] end subgraph After["After import and a clean plan"] O2["Remote object: covers bucket"] C2["Configuration: resource block"] S2["State: binding plus attributes"] O2 --- S2 S2 --- C2 end Before -->|"import block or terraform import"| After

Importability is a property of each resource type, not of Terraform. A resource can be imported only if its provider implements the import logic, and the "Import" section at the bottom of each resource page in the registry says whether it does and in which format. Problems importing a particular type are reported to the provider repository. Authors who want to add import support to their own provider find the contract in the plugin development documentation, which this section describes on Provider Development.

Identifying the object: id and identity

Every import has to say which remote object is meant. Terraform supports two ways of saying it, and a resource may support one or both.

Form What it is

id

A single string issued by the cloud, such as an S3 bucket name, an RDS instance identifier or a Google Cloud project/name pair. Its format depends on the resource type and is documented in the provider’s "Import" section. This is the only form available in the terraform import command and in import blocks before Terraform 1.12.

identity

An object of named values that together identify the resource, defined by the provider as the resource’s identity schema. The keys and values are specific to the resource type and provider. Terraform 1.12 added the identity argument to import blocks, and OpenTofu 1.12 followed.

The official overview gives the AWS provider’s S3 bucket as the example: its identity is made of account_id, bucket and region, whereas aws_instance is identified by its id. The provider documentation lists the identity schema under each resource’s "Import" section, with the required attributes first and the optional ones (for S3 buckets account_id and region) after. Two import forms for the same covers bucket look like this in the AWS provider documentation:

# id form: the bucket name
import {
  to = aws_s3_bucket.covers
  id = "bookshelf-covers-example"
}

# identity form (Terraform 1.12 and later): the same bucket described by its identity schema
import {
  to = aws_s3_bucket.covers
  identity = {
    bucket = "bookshelf-covers-example"
  }
}

Identity has three practical advantages over a bare id string. It is a structured value, so a provider does not have to invent a delimiter syntax and users do not have to escape one. It is stable across runs, which is why HCP Terraform uses it to recognize that an object found by a search is already managed by another workspace. And it is what terraform query returns for every result, which makes the query output directly usable as import input. A resource that has not been updated by its provider to support identity still imports with id only, and the OpenTofu documentation says so explicitly: not all resource types have been updated to support identity yet.

An import block cannot contain both arguments. Choose one: id and identity are mutually exclusive.

Inspecting identities already in state

terraform state identities lists the identities of the resource instances that are already in state, in JSON. It is the quickest way to learn what identity a provider stores for a type, and to see what an import produced. The -json flag is required, resources without an identity are omitted, an optional address filters by resource or module, and -id=<id> filters to the instance whose id attribute equals the string.

$ terraform state identities -json aws_s3_bucket.covers
# abbreviated and illustrative
{
  "aws_s3_bucket.covers": {
    "account_id": "111122223333",
    "bucket": "bookshelf-covers-example",
    "region": "eu-west-1"
  }
}

The values above are placeholders (the account number is the conventional example one), and the exact keys are decided by the provider version in use.

Choosing between the tools

Terraform offers several ways to reach the same state. They differ in whether the step is reviewed, whether configuration is written for you, and how many objects one run can handle.

terraform import command import block import block with -generate-config-out terraform query with -generate-config-out

Writes to

State immediately

State, when the plan is applied

State, when the plan is applied

Nothing in state. It prints or generates import and resource blocks that you apply afterwards

Reviewed in a plan

No

Yes

Yes

The later import is reviewed. The query itself only reads

Writes the resource block

No, you write it first

No, you write it

Yes, as a template

Yes, for the results, together with the import blocks

How many objects

One per run

Many, with for_each or several blocks

Many

Everything a list block finds, up to its limit

Needs object ids

Yes

Yes

Yes

No, they are discovered

Needs Terraform

Any

1.5, 1.6 for expressions in id, 1.7 for for_each, 1.12 for identity

1.5

1.14

The documentation’s guidance is consistent. Write the resource block yourself when you know how to configure all or most of its arguments. Use generated configuration when importing several resources or one complex resource whose configuration you do not already have. Use the bulk workflow when you cannot easily get at the ids at all. The CLI command remains for one-off repairs and for scripts that predate import blocks.

Terraform core, a provider, the remote API and the state file, showing that core reads and writes state while the provider talks to the API

The terraform import command

terraform import is the original mechanism. Its syntax is terraform import [options] ADDRESS ID. Terraform finds the object from the ID and writes it to state at ADDRESS. The address must be a valid resource address, and because any resource address is valid, the command can import into a module’s resource as well as into the root, and into a single instance of a resource that uses count or for_each.

The prerequisites are the same as for blocks, with one difference in the order of work. The command only changes the state: it does not generate configuration. You write the resource block first (it does not have to be complete, you can finish the arguments after the import), run the command, then run terraform plan and adjust the block until the plan shows no unexpected changes.

# 1. a minimal destination block already exists in main.tf:
#    resource "aws_s3_bucket" "covers" {}

# 2. bind the existing bucket to it (the id is the bucket name)
terraform import aws_s3_bucket.covers bookshelf-covers-example

# 3. see what the empty block would change, and fill in the block until the plan is clean
terraform plan

Addressing modules and indexed instances

The address can reach into modules and into expanded instances. Quote it so the shell does not interpret brackets and double quotes.

# a resource inside a module
terraform import module.database.aws_db_instance.postgres bookshelf-postgres

# the first instance of a resource that uses count
terraform import 'aws_instance.web[0]' i-abcd1234

# the "example" instance of a resource that uses for_each (Linux, macOS, other UNIX shells)
terraform import 'aws_instance.web["example"]' i-abcd1234

The quoting differs by shell. The reference shows 'aws_instance.baz[\"example\"]' for PowerShell and an unquoted aws_instance.baz[\"example\"] for Windows cmd.exe. Instance ids in the examples (i-abcd1234) are the documentation’s placeholders.

Flags and their meaning

All flags are optional.

Flag Meaning

-config=path

Directory of configuration files that configure the provider for the import. Defaults to the working directory. If it contains no configuration files, the provider must be configured through prompts or environment variables.

-input=true

Whether to ask for input for provider configuration.

-lock=false

Do not hold a state lock during the operation. Dangerous if others can run commands against the same workspace.

-lock-timeout=0s

How long to retry acquiring the state lock.

-no-color

No color in the output.

-parallelism=n

Limit on the number of concurrent operations while walking the graph. Defaults to 10.

-provider=provider

Deprecated. Overrides the provider configuration used for the import. By default Terraform uses the provider of the target resource, which is almost always right. Use the provider argument of an import block instead.

-var 'foo=bar' and -var-file=foo

Set variables in the configuration. terraform.tfvars and *.auto.tfvars in the current directory are loaded automatically. The reference notes that -var-file is only useful together with -config.

-ignore-remote-version

Accepted only with the HCP Terraform CLI integration or the remote backend.

-state, -state-out, -backup

Legacy options accepted only with the local backend.

Provider configuration during an import

Terraform loads the configuration files to learn how the provider is configured, with one limitation: the provider configuration used by an import must not depend on non-variable inputs. A provider block that reads a data source, for example, cannot be used, because nothing has been read yet. Where no configuration is present Terraform prompts, and provider environment variables such as the AWS credentials chain work as usual. Keep credentials out of files: use the provider’s environment variables or an OIDC role, as described in Providers.

Complex imports

Some imports bring in more than one object. The usage page’s example is an AWS network ACL, which imports the aws_network_acl and also one aws_network_acl_rule per rule. The secondary resources do not exist in configuration, so you must read the import output and create a resource block for each of them. If you do not, the next run plans to destroy the objects that were imported. If you want to rename or move imported resources afterwards, the terraform state commands or a moved block can do it, see State.

Limits of the command

The command has no plan. A mistake, such as importing the right object to the wrong address, is written straight to state, and the only reviewable step comes after. It handles one object per run, so importing 40 objects means 40 invocations and 40 state locks. It cannot be code-reviewed in a pull request, and with HCP Terraform it runs locally: commands such as apply run in HCP Terraform, but import does not have access to the information stored in the remote workspace, so you may need to set local variables equivalent to the workspace variables. For these reasons the official documentation now points to the import block as the way to automate imports in CI/CD pipelines.

The import block

An import block states, in configuration, that Terraform should bind a remote object to an address. Because it is configuration, the import appears in the plan, is reviewed in a pull request, runs in CI like any other change, and can be repeated safely. The block was introduced in Terraform 1.5 (2023-06-12) for the root module and has gained arguments and places to live since, summarized in What changed recently.

Arguments

Argument Type Meaning

to

address, required

The address of the resource instance that receives the object. It must match the address of a resource block in configuration, unless configuration is being generated. For an instance of a resource that uses count or for_each add the index: aws_instance.example[0] or aws_instance.example["env"]. For a resource in a module use the module prefix, such as module.instances.aws_instance.example.

id

string

The provider’s id for the object. A string or an expression that evaluates to a string, and it must be known during the plan. Its format depends on the resource type. Mutually exclusive with identity.

identity

map

An object of key-value pairs that identifies the object, defined by the provider’s identity schema. Mutually exclusive with id.

for_each

map or set of strings

Repeats the block to import several similar objects without separate blocks, exposing each.key and each.value. Available since Terraform 1.7.

provider

reference

Imports with a specific provider configuration, such as aws.europe, the same meta-argument that resources use.

The reference recommends either a single imports.tf file for all import blocks or placing each block beside its destination resource block. Both work. A single file is easy to delete after the migration. Co-location documents the origin of the resource where somebody maintaining the resource will read it.

A first import, with the resource written by hand

This is the workflow the documentation describes as the one to prefer when you know how to configure the resource. It has four steps: define the destination resource block, add the import block, run terraform plan and check that the import plan is what you want, then apply.

terraform {
  required_version = ">= 1.12.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "eu-west-1"
}

# The destination block. Only arguments with non-default values need to be present.
resource "aws_s3_bucket" "covers" {
  bucket = "bookshelf-covers-example"

  tags = {
    app = "bookshelf"
  }
}

import {
  to = aws_s3_bucket.covers
  id = "bookshelf-covers-example"
}
$ terraform plan
# abbreviated and illustrative
aws_s3_bucket.covers: Preparing import... [id=bookshelf-covers-example]
aws_s3_bucket.covers: Refreshing state... [id=bookshelf-covers-example]

Terraform will perform the following actions:

  # aws_s3_bucket.covers will be imported
    resource "aws_s3_bucket" "covers" {
        bucket = "bookshelf-covers-example"
        ...
    }

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

The plan summary has its own counter for imports: 1 to import, 0 to add, 0 to change, 0 to destroy. If the same plan also shows an update to the imported resource, the resource block disagrees with the real object, and you should adjust the block until the only line is the import, or accept the update knowingly. After terraform apply Terraform records that it imported the resource and did not create it, and the final line reads Apply complete! Resources: 1 imported, 0 added, 0 changed, 0 destroyed.

Import blocks are idempotent

An applied import block does no harm when it stays in the configuration. After the resource is in state, the next plan does not plan the import again, and importing into the same address twice has no effect. The documentation therefore allows both choices: remove the blocks after the import, or keep them "as a record of the resource’s origin for future module maintainers". Keeping them makes the repository a record of which resources were adopted and which were created. Removing them keeps the code free of historical noise. Whichever you choose, apply the same policy across the project.

Because the block lives in configuration, terraform plan also validates it. Terraform 1.10 improved the validation of import blocks to catch more invalid cases during terraform validate, and Terraform 1.5 made check blocks run during an import, so a health check declared with the resource can verify the imported object immediately, see Lifecycle, Validation and Checks.

Dynamic values in the id

Terraform 1.5 only accepted a literal string for id. Terraform 1.6 (2023-10-04) made id accept expressions that refer to other values, such as resource attributes, as long as the value is a string known at plan time. Terraform 1.7 added for_each. Two restrictions are worth remembering.

  • The id must be known when the plan is computed. An id that comes from an attribute that is only known after apply cannot be used.

  • The for_each expression cannot refer to the import target (a fix of Terraform 1.12 closed that loophole), and an id that references the to resource itself crashed Terraform in older releases until a Terraform 1.9 patch fixed the crash, so treat the target as unavailable inside its own import block.

Importing several instances

The destination resource block can use count or for_each, and each import then names the instance that receives the object.

# Import one specific instance of a resource that uses for_each
import {
  to = aws_instance.web["catalog-api"]
  id = "i-abcd1234"
}

resource "aws_instance" "web" {
  for_each      = toset(["catalog-api", "reviews-api"])
  ami           = "ami-12345678"
  instance_type = "t3.micro"
}

The documentation’s own example imports into the env key of a map. The principle is the same for count, where the index is a number such as aws_instance.example[0]. Instances that are not named by an import block are planned for creation in the usual way, so an incomplete list of imports on a resource with several instances creates the remaining ones.

Importing many with for_each

Since Terraform 1.7 one block can import a collection. The for_each of the block pairs with the for_each of the destination resource, and the keys of the map line up the object with the instance.

locals {
  # key => id of the existing bucket. Here the keys are the environments Bookshelf runs in.
  covers_buckets = {
    staging = "bookshelf-covers-staging"
    prod    = "bookshelf-covers-prod"
  }
}

import {
  for_each = local.covers_buckets
  to       = aws_s3_bucket.covers[each.key]
  id       = each.value
}

resource "aws_s3_bucket" "covers" {
  for_each = local.covers_buckets
  bucket   = each.value
}

The reference also shows a list of objects, each describing a module instance and the resource inside it, imported into the address module.group[each.value.group].aws_s3_bucket.this[each.value.key]. That is the pattern for adopting a whole family of resources that already exist in several modules. Terraform 1.10.1 removed a restriction that an expanded resource block could only be used together with imports that use for_each, so a count or for_each resource can now be combined with separate import blocks as shown in the previous section.

A map built by hand is fine for a handful of objects. For dozens, keep the ids in a data file and decode it into a local, so the configuration does not change when the inventory does.

locals {
  # imports.json contains: { "staging": "bookshelf-covers-staging", "prod": "bookshelf-covers-prod" }
  covers_buckets = jsondecode(file("${path.module}/imports.json"))
}

An inventory file like that can come from a cloud CLI listing, from a spreadsheet exported as CSV (use csvdecode), or from the output of terraform query -json, described later. Because file() is evaluated at plan time, the ids are known when Terraform needs them. The functions themselves are described on Functions.

Selecting a provider configuration

When the object belongs to a non-default provider configuration, name it. The same alias must be used by the destination resource.

provider "aws" {
  region = "eu-west-1"
}

provider "aws" {
  alias  = "us"
  region = "us-east-1"
}

import {
  provider = aws.us
  to       = aws_s3_bucket.covers_replica
  id       = "bookshelf-covers-us"
}

resource "aws_s3_bucket" "covers_replica" {
  provider = aws.us
  bucket   = "bookshelf-covers-us"
}

Multi-region and multi-account providers are described on Providers. The provider meta-argument is the replacement of the deprecated -provider flag of the command. Terraform 1.16 patch releases fixed a bug in import provider resolution, and Terraform 1.16.0 made import blocks respect provider local names, which matters when required_providers gives a provider a name different from its source address.

Import blocks and modules

There are two different things to distinguish.

Importing into a resource that is inside a module has been possible since the block was introduced. The block lives in the configuration where you run Terraform, and its to argument carries the module prefix:

# main.tf in the root module
import {
  to = module.instances.aws_instance.example
  id = "i-abcd1234"
}

The matching resource "aws_instance" "example" block is in the module, in the documentation’s example instances/main.tf. Since Terraform 1.9 an import block that points to a module that does not exist is an error, where earlier versions silently ignored it. If an upgrade surfaces such an error, fix the address or remove the block.

Declaring the import block itself inside a module became possible in Terraform 1.16 (2026-08-26): the release notes list the line that import blocks inside modules are now supported. Before that, import blocks were a root-module feature, which forced module authors to ask every caller to add the import in their root. With 1.16 a module can carry its own imports, which is useful in two situations: a module that wraps an existing resource and ships the import next to it, and a reusable "adopt" module for the common objects of an organization. The official reference documents only the root-to-module example above. The semantics of the to address when the block is inside a child module (whether it is relative to that module) are not described on the pages I checked, so verify them against your own plan output before publishing a module that relies on them. A Terraform 1.15 patch release fixed child module validation so that invalid blocks, among them list, import, backend and cloud, now raise diagnostics.

The general design of modules, including whether a module should adopt objects at all, belongs to Modules and Module Development and Publishing.

Imports and destroy

Imports are processed as part of planning, so a terraform destroy also evaluates them. Terraform 1.9 fixed a case in which an import block could block a destroy operation when the target had already been deleted, and OpenTofu 1.9 skips the import logic on tofu destroy. Keeping import blocks around is therefore safe in the supported versions, but if you maintain a configuration across old Terraform releases remove them once the migration is complete.

Generating configuration with -generate-config-out

Writing the resource block for a complex object, a database with 40 attributes, a load balancer with listeners, is the tedious part of an import. Terraform can generate it. Add an import block with no matching resource block and run terraform plan with -generate-config-out=<path>, where the path is a new file. Terraform asks the provider for the attributes of the object and writes a resource block with its best guess at the value of each argument.

The feature has been available since Terraform 1.5 and is still documented as experimental: later minor versions may change the formatting of the generated configuration and the behavior of the flag.

Workflow

  • Add the import block, in imports.tf or any file. The to argument becomes the address of the generated resource, and id (or identity) points at the object.

  • If no other resource in the configuration uses the provider, add a provider block so that Terraform knows which one to use, and run terraform init again when you add it.

  • Run terraform plan -generate-config-out=generated.tf. Terraform shows the import plan and writes the file. The path must not exist, or Terraform reports an error.

  • Review the file. Move the resources to the right files or modules, remove arguments you do not need, replace literals with variables and references.

  • Run terraform apply, then commit the configuration.

# imports.tf
import {
  to = aws_db_instance.postgres
  identity = {
    identifier = "bookshelf-postgres"
  }
}
$ terraform plan -generate-config-out=generated.tf
# abbreviated and illustrative
aws_db_instance.postgres: Preparing import... [identifier=bookshelf-postgres]
aws_db_instance.postgres: Refreshing state... [identifier=bookshelf-postgres]

Terraform will perform the following actions:

  # aws_db_instance.postgres will be imported
  # (config will be generated)
    resource "aws_db_instance" "postgres" {
        ...
    }

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. Please review the configuration and edit it as
necessary before adding it to version control.

The shape of the output above follows the documentation’s aws_iot_thing example, with the Bookshelf names substituted. The full generated file for a real PostgreSQL instance is long and differs per provider version, so it is not reproduced. terraform plan itself accepts the flag as an experimental option: it generates HCL for any imported resource that is not already present, writes it to a new path, and if the plan fails for another reason Terraform may still attempt to write configuration.

Cleaning up generated configuration

Generated HCL is a starting point, and a good generator output is not necessarily good configuration. The official advice is to iterate: remove some attributes, adjust the value of others, and rearrange the blocks into files and modules. In practice, three kinds of edit are worth doing every time.

  • Remove arguments that only restate defaults or are computed. A shorter block is easier to review and survives provider upgrades better. After each removal run terraform plan: the goal is a plan that shows no changes for the imported resource.

  • Replace literals with references. The generated block contains a subnet group name or a security group id as a string. Where Bookshelf already manages the network in the same configuration, a reference such as aws_db_subnet_group.postgres.name makes the dependency visible to the graph.

  • Never commit secrets. Do not put real passwords in generated code. Values for sensitive arguments come from variables or from a secrets store, see Sensitive Data, Ephemeral Values and Write-only Arguments. The OpenTofu documentation states that for sensitive or write-only attributes the generated value is null with a comment hint, so the placeholder has to be replaced by hand. The Terraform page does not describe this case, so verify it on the version you use.

Limitations

The configuration is built from the values the provider returns. For resources with complex schemas the provider can return values that are individually valid but not valid together. The documentation’s example is ipv6_address_count conflicting with ipv6_addresses on aws_instance. Terraform still writes the file, reports a Conflicting configuration arguments error, and you fix the block by removing one of the two arguments, then run terraform plan again. Resources that are already in state are skipped, so run the command again after a first import and nothing more is generated for them.

Several fixes over the years improved the output: Terraform 1.8 generates strings that contain valid JSON as calls to jsonencode rather than as one long string, which is easier to read and lets you replace part of it with an expression, and Terraform 1.10 simplified the generation of string attributes that contain numbers or booleans. OpenTofu’s documentation adds a caveat of its own: generating configuration is currently not possible when for_each is used on import blocks. The Terraform documentation does not list that limitation, so test a for_each import with -generate-config-out before relying on it.

Bulk import

Bulk import covers two different problems that are easily confused: you know the ids and you do not. They need different tools.

Situation Tool Result

You have an inventory of ids

import blocks with for_each, optionally with -generate-config-out

One reviewable plan that adopts all of them.

You need to find what exists

list blocks in a .tfquery.hcl file and terraform query

A list of discovered objects and, with -generate-config-out, ready-made import and resource blocks.

The objects live in a cloud with its own exporter

The cloud’s export tool, for example gcloud beta resource-config bulk-export

Terraform HCL to review, then import.

The first row is the for_each pattern of the previous sections. The remainder of this section describes the rest.

List resources and terraform query

Until Terraform 1.14 every import started from an id you already had. Discovering what exists was left to cloud CLIs and console exports, and the result had to be turned into ids by hand. Terraform 1.14.0 (2025-11-19) added list resources: HCL queries stored in .tfquery.hcl files, and a new command, terraform query, that runs them. A list resource is implemented by the provider next to the ordinary resource of the same type, so it reuses that resource’s schema and identity. It is a read-only operation: it does not plan or apply anything and does not change infrastructure.

sequenceDiagram participant U as Engineer or CI participant T as terraform query participant P as Provider participant A as Cloud API U->>T: terraform query -generate-config-out=generated.tf T->>T: Read .tfquery.hcl files and list blocks T->>P: List request for each list block (limit, config, include_resource) P->>A: Search the account, project or region A-->>P: Matching objects P-->>T: Identity of each object (and full data when requested) T-->>U: Results grouped by list block T->>U: Write import and resource blocks to generated.tf U->>U: Review, move blocks into the configuration U->>T: terraform apply T->>P: Import into state

Requirements

  • Terraform 1.14 or later for terraform query, .tfquery.hcl files and list blocks. HCP Terraform needs a workspace on 1.14 or newer for the search feature.

  • Terraform 1.12 or later to search by resource identity, which is also what lets HCP Terraform know that a resource is already managed in another workspace.

  • A provider version that implements a list resource for the type you want. Not every resource type has one. The AWS provider documents list resources for types such as aws_s3_bucket, aws_db_instance and aws_instance, and the Google provider documents them for google_storage_bucket, google_service_account, google_compute_instance, google_dns_managed_zone and others, in a list-resources folder of each provider’s documentation. The AWS documentation for some of them still labels the list resource a beta technical preview, so check each page before you depend on it.

  • Policy evaluation of query results needs Terraform 1.17.0 or later (see Evaluating policies against query results).

The query file

The query is split across the standard configuration and a query file. The standard .tf configuration declares required_providers (and a cloud block if you want to compare results with HCP Terraform workspaces), so that terraform init installs the providers. The query file has the extension .tfquery.hcl and contains list blocks. list blocks are only valid in .tfquery.hcl files. Terraform 1.15 (2026) made terraform fmt format these files.

Argument Meaning

list "<TYPE>" "<LABEL>"

The block header: the resource type to search for (provider specific) and a label, unique in the directory. The block is referred to as list.<type>.<label>.

provider

Required. The provider configuration the query uses. It is read from the terraform block of the main configuration, but a provider block in the query file can declare an alternative.

config

A block of provider-specific arguments, such as regions, filters or projects. See the provider’s list resource page.

include_resource

Boolean. By default only the resource identity of each result is returned. With true, Terraform also retrieves the attributes. It is required to read attributes of results from other expressions and to supply policy input, and it can affect performance.

limit

Maximum number of results for the block, by default 100. When the limit is reached Terraform stops the query and stops reporting results. It can be a reference that resolves to a number.

count, for_each

Meta-arguments for running several queries from one block. They are mutually exclusive.

A query file can also contain variable and locals blocks, to parameterize a query that is reused with different inputs, and provider blocks for alternative provider configurations.

For Bookshelf, three queries answer the first questions of an adoption project: which S3 buckets exist in the region, which running instances carry the Bookshelf name prefix, and which RDS instances exist.

# main.tf
terraform {
  required_version = ">= 1.14.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "eu-west-1"
}
# bookshelf.tfquery.hcl
list "aws_s3_bucket" "all" {
  provider = aws
}

list "aws_instance" "running" {
  provider = aws
  limit    = 50

  config {
    filter {
      name   = "tag:Name"
      values = ["bookshelf-*"]
    }
    filter {
      name   = "instance-state-name"
      values = ["running"]
    }
  }
}

list "aws_db_instance" "databases" {
  provider = aws
}

The filter block shape (name, values, several filter blocks all having to match) and the include_auto_scaled option come from the AWS list resource for aws_instance, which also documents that instances managed by an Auto Scaling group and instances in the terminated or shutting-down state are excluded by default. aws_s3_bucket and aws_db_instance accept an optional region argument that defaults to the provider region.

The Google provider follows the same layout, with a config block of its own. For Cloud Storage buckets the only argument is an optional project, which defaults to the provider project, and the results carry the bucket name and the project as identity.

# bookshelf-gcp.tfquery.hcl
list "google_storage_bucket" "all" {
  provider = google

  config {
    # optional, defaults to the project of the provider
    # project = "bookshelf-prod-example"
  }
}

The Google provider also publishes a guide that explains the file layout, the Terraform version requirement (1.14 or later) and the shared list arguments for that provider.

Parameterizing and repeating queries

Query files accept the same parameterization as the rest of the language. A variable lets the same file serve several environments, and count or for_each run one query for each element.

variable "max_results" {
  type    = number
  default = 200
}

list "aws_s3_bucket" "all" {
  provider = aws
  limit    = var.max_results
}

list "aws_db_instance" "databases" {
  provider = aws
  count    = 2
}

The block reference says that limit can be a reference that resolves to a number, and that count is most suitable for several identical or nearly identical lists, while for_each suits lists built from the attributes of a map or a set (its examples use tomap() and toset()). The count = 2 above only illustrates the syntax; in practice count is usually driven by a variable, as in the reference’s example that runs one query per subnet id. Values for variables are given with -var or -var-file, in addition to terraform.tfvars and *.auto.tfvars.

Running terraform query

terraform query [flags] [options] checks the current directory for .tfquery.hcl files, queries remote infrastructure for the list blocks, and prints the results. By default each result contains a reference to the list block in the form list.<type>.<label> and the identity of the discovered object. The provider may add other information, such as a description. The number of results depends on the provider and on the constraints in the query.

Flag or option Meaning

-generate-config-out=<path>

Generates import and resource blocks for the results into a new file. The file must not exist. With -json the configuration is part of the JSON output instead of a file.

-json

Prints the results in JSON format, the machine-readable choice for scripts.

-no-color

Monochrome output.

-var 'name=value', -var-file=<file>

Set input variables declared in the query configuration, in addition to terraform.tfvars and *.auto.tfvars.

-policies=<path>

Selects a policy-set directory for HCP Terraform to evaluate against the results. May be repeated. Terraform 1.17.

$ terraform init
$ terraform query
# abbreviated and illustrative: the exact layout depends on the Terraform and provider versions
list.aws_s3_bucket.all   bucket=bookshelf-covers-example  account_id=111122223333  region=eu-west-1
list.aws_s3_bucket.all   bucket=bookshelf-legacy-uploads  account_id=111122223333  region=eu-west-1
list.aws_db_instance.databases   identifier=bookshelf-postgres

The output above is deliberately schematic: the documentation describes what each result contains (the list reference and the identity) but does not publish a verbatim console transcript, so no real layout is claimed. The identity values are those of the buckets and instances that the provider found.

From results to imports

Add -generate-config-out to write the import and resource blocks, including the resource identity, for the results. The path must not exist when you run the command. To generate again, delete the file first, otherwise Terraform reports an error.

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

# review generated.tf, then copy the blocks you want to adopt into the configuration
terraform plan
terraform apply
# generated.tf (illustrative shape: the addresses and attributes depend on the Terraform and provider versions)
import {
  to = aws_s3_bucket.covers
  identity = {
    bucket     = "bookshelf-covers-example"
    account_id = "111122223333"
    region     = "eu-west-1"
  }
}

resource "aws_s3_bucket" "covers" {
  bucket = "bookshelf-covers-example"
  # ...
}

The addresses above are placeholders: review the generated names and replace them with meaningful ones. Even when you are connected to HCP Terraform or Terraform Enterprise, running terraform query -generate-config-out creates the file on the local workstation. The next step is the ordinary import workflow: copy the blocks into the configuration (main.tf in the documentation), run terraform plan and terraform apply, then discard generated.tf. As with single imports the generated import block may then be removed or kept as a record.

With -json the same generated configuration is part of the JSON stream, which suits a script that selects objects by tag before writing anything:

terraform query -json -generate-config-out=generated.tf

In the page’s own example the JSON variant is shown as terraform query -generate-config-out=file.tf -json, and the documentation states that with -json the configuration is part of the output instead of a file.

Query results and what they are not

A query is a read. A few boundaries are worth stating because they trip up first-time users.

  • The query lists what the provider can see with the credentials in use. An object in another account, project or region is not found unless the list block says where to look (config, an aliased provider, for_each).

  • The default result has the identity only. Anything that needs the attributes, for example to pick resources by tag in a script, requires include_resource = true, at the cost of extra reads. Generating configuration also needs the full data, and -generate-config-out requests it even when include_resource is not set.

  • The limit argument caps each block at 100 results unless raised. A query that quietly stops at the limit looks like a complete inventory, so raise limit for large accounts and compare counts with the cloud console.

  • Query results are not state. Running terraform query does not mark anything as managed. HCP Terraform adds a check against the resources its workspaces manage (see below).

  • Offline validation of query files is supported in the validate command through a -query flag according to the 1.14 changelog. I did not find the flag on the validate reference page, so confirm it with terraform validate -help.

Evaluating policies against query results

Terraform 1.17 makes Terraform Policy generally available, and with it the -policies flag of terraform query no longer needs an experimental build. In HCP Terraform, remote queries can evaluate policies against configuration generated from the discovered objects before you decide what to import. The policy input is generated from full resource data, so the list blocks need include_resource = true (or the command needs -generate-config-out). The -policies flag alone does not request resource data.

# evaluate the policy sets of the workspace and an explicit directory against the results
terraform query -policies=./policies

# the same, and also write importable configuration (the file must not exist)
terraform query -policies=./policies -generate-config-out=generated.tf

# repeat the flag for several directories
terraform query -policies=./policies/security -policies=./policies/tagging

The prerequisites are the ones a remote run needs: a workspace on Terraform 1.17 or newer with remote or agent execution, an organization with Terraform policy and search policy evaluation enabled, HCP Terraform credentials with read and write permission for the workspace, and provider credentials in the workspace that can read the queried resources. The cloud block connects the working directory to the workspace. Explicit policy directories must exist in the working directory so that they are uploaded with the configuration. Each discovered resource ends with one of five outcomes.

Result Meaning

Passed

The generated configuration of the resource satisfies the evaluated policies.

Failed

At least one policy rejects it. Read the diagnostics printed with the result.

Unknown

Terraform cannot decide, for example because attributes or relationship data are unavailable.

Error

Policy evaluation itself failed. Read the diagnostics.

N/A

Policies exist, but none targets the resource type of the list block. It is not a passing result.

These results are informational. Query-time failures, including failures of mandatory policies, do not block the import and do not replace plan-time enforcement, and a query with no failing policies does not prove that all resources pass during plan and apply. When the import is planned in the same workspace, HCP Terraform evaluates its policies again against the import plan. The policy language and enforcement levels are described on Policy as Code.

Search and import in HCP Terraform

HCP Terraform and Terraform Enterprise expose the same discovery in the web interface. A workspace needs Terraform 1.14 or newer, cloud credentials for the providers to search, and read and write permission. Resources managed by another workspace are recognized when that workspace uses Terraform 1.12 or newer. Agents run the query operation by default.

Queries are defined in one of two ways.

  • Build a query in the UI. In the workspace choose Search & Import, then New query and Build query via UI. Select the providers, then the resource types, optionally filter on predefined filters or on any attribute that Terraform tracks for the type, and run the query.

  • Define a query in Terraform configuration. Add list blocks to the workspace configuration, push them (VCS-driven workflow) or apply them from the CLI (CLI-driven workflow), then choose Fetch latest configuration in the workspace and start a new query.

In both cases the Start new query dialog offers Generate starter configuration, which loads additional resource data and generates configuration for the results, at the cost of a longer query.

The results table has an IaC status column that compares what was discovered with what HCP Terraform manages.

Status Meaning

Managed

The resource has the same identity as one managed by a workspace with a similar provider version.

Unknown

The resource has the same identity as one managed by an older Terraform or provider version. Verify before importing. When a provider changes its identity definition between versions, HCP Terraform can list a resource as Unknown rather than Unmanaged.

Unmanaged

Not managed by Terraform. It can be imported.

Filters narrow the table by resource name or identifier, resource type or IaC status, and selecting a resource shows the attributes the provider returned. Select one or more instances, choose Generate Starter Configuration, and copy the import and resource blocks into the configuration. For VCS-driven workspaces commit and push and start a new run. For CLI-driven workspaces plan and apply from the terminal. Modify the generated configuration if the plan fails. If you did not have HCP Terraform generate the configuration, copy only the import blocks and let terraform plan -generate-config-out generate it locally, as described earlier. This is the one place where the "managed by another workspace" check protects against the most dangerous import mistake: binding one remote object to two states.

The workspace model, permissions, runs and policy sets of HCP Terraform are described on HCP Terraform and Terraform Enterprise.

Exporting from a cloud: the Google Cloud example

Terraform’s own discovery depends on the provider implementing list resources. Cloud vendors also ship exporters that produce Terraform HCL from an inventory of what exists. Google Cloud’s is gcloud beta resource-config bulk-export, which can emit Terraform for a project, folder or organization. Its use, flags and limitations (not every resource type is supported, no Windows support, the output is a starting point to review and prune) are documented on Google Cloud Infrastructure as Code: bootstrapping Terraform from existing resources, which also shows the command line.

The two approaches combine well. A pragmatic adoption of a Google Cloud project is to export HCL for the resource types the exporter supports, review and prune it, import it into state with import blocks, and then use terraform query with the Google list resources (for example google_storage_bucket or google_service_account) to find the objects the exporter missed or that are no longer needed. The export documentation itself describes the generated code as something to review and the import as the following step.

# imports.tf for a covers bucket found by the export, Google provider identity form
import {
  to = google_storage_bucket.covers
  identity = {
    project = "bookshelf-prod-example"
    name    = "bookshelf-covers-example"
  }
}

The Google provider also accepts the older id forms (name or project/name) for that resource, and it infers the project from the provider block or the environment when the id has no project. Authentication for the export and for the provider is covered on Terraform on Google Cloud and, for the cloud side, on Identity and Access.

After the import

An imported resource is not finished when the apply succeeds. A safe order of work keeps the adoption reviewable.

  1. Make the plan empty. Run terraform plan until it shows no change for the imported resources. Every pending update means the block and the real object disagree.

  2. Decide what is intentional. If the object has a setting you do not want to manage, for example a field maintained by a pipeline, express that with ignore_changes rather than deleting the argument, see Lifecycle, Validation and Checks. If a resource must never be destroyed by a typo, add prevent_destroy before anyone else touches the code.

  3. Wire it into the rest of the configuration. Replace literal ids with references so that the dependency graph is correct, see The Dependency Graph and the Plan.

  4. Restructure last. Moving imported resources into modules or renaming them is a separate change made with moved blocks, see Refactoring: moved, removed and import.

  5. Verify identity. terraform state identities -json shows what was recorded. It is also the right check when the object is expected to be recognized by HCP Terraform as managed.

  6. Run it through the normal pipeline. The same import blocks apply in CI, which is why they replaced the command in automated flows, see CI/CD and Automation.

Imports of data that carries secrets deserve a final reminder. Everything the provider read about the object, including passwords or tokens that are returned by the API, is written to the state. Treat the state of a freshly imported database as sensitive, as described on State.

Terraform and OpenTofu differences

OpenTofu has the same import blocks and the same tofu import command. It documents these points differently.

Aspect Terraform 1.17 OpenTofu 1.13

import block

Since 1.5. Arguments to, id, identity, for_each, provider.

Same arguments. for_each since 1.7, identity since 1.12. The page says identity is the newer approach and not all resources support it yet.

Blocks in non-root modules

Supported since 1.16.

The OpenTofu page says an import block can be added to any configuration file and shows a root block targeting a module resource. It does not state whether blocks inside child modules are supported.

Configuration generation

plan -generate-config-out, experimental. No documented limitation with for_each.

tofu plan -generate-config-out, experimental. Generation is currently not possible with for_each on import blocks. Sensitive and write-only attributes are generated as null with a comment.

List resources, .tfquery.hcl, query command

1.14 and later.

No query command page in the OpenTofu documentation I checked, and the import page does not mention list or query blocks.

A configuration that uses .tfquery.hcl files, list resources or import blocks inside modules is therefore Terraform specific. One that sticks to import blocks with id or identity and for_each in the root module is portable between the two tools, provided the OpenTofu version is 1.12 or later for identity. See OpenTofu for the wider comparison.

What changed recently

Version Change

Terraform 1.5 (2023-06-12)

import blocks for the root module and terraform plan -generate-config-out. id had to be a literal string. check blocks also run during an import.

Terraform 1.6 (2023-10-04)

The id of an import block accepts expressions that evaluate to a string known at plan time.

Terraform 1.7 (2024-01-17)

for_each in import blocks (OpenTofu 1.7 added it as well).

Terraform 1.8 (2024-04-10)

Generated configuration renders strings that contain valid JSON as jsonencode calls. Patch releases fix crashes when generating configuration for resources with complex sensitive attributes.

Terraform 1.9 (2024-06-26)

import blocks that point to non-existent modules raise an error instead of being ignored. An import block no longer blocks a destroy when the target is already gone (OpenTofu 1.9 also skips import logic on destroy).

Terraform 1.10 (2024-11-27)

Better validation of import blocks during terraform validate. Simpler generated configuration for string attributes holding numbers and booleans. Terraform 1.10.1 removes the restriction that an expanded resource block must be used with for_each imports.

Terraform 1.12 (2025-05-14)

identity argument in import blocks, mutually exclusive with id. A for_each expression of an import block can no longer reference the target. Resource identity is included in the apply output of an import (1.12.1). OpenTofu 1.12 adds identity as well.

Terraform 1.14 (2025-11-19)

List resources in .tfquery.hcl files, the terraform query command, and a provider RPC that lets providers generate more precise values when importing.

Terraform 1.15

terraform fmt formats .tfquery.hcl files. Patch releases make child module validation raise diagnostics for invalid list, import, backend and cloud blocks, and make the cloud backend forward -generate-config-out to the query run.

Terraform 1.16 (2026-08-26)

import blocks inside modules, and import blocks respect provider local names. The -policies flag of terraform query as an experimental option. Patch releases fix import provider resolution, import blocks ignored when several imports targeted instances of one for_each or count resource, and a panic when an identity references a sensitive value.

Terraform 1.17 (2026-10-21)

Terraform Policy generally available: -policies for plan, apply and query no longer needs an experimental build, and query policy evaluation reports Unknown and N/A results. A path that is not a directory is rejected.

Where the books are outdated or wrong

Material written before Terraform 1.5 teaches importing as a two-step manual routine: write an empty resource block, run terraform import ADDRESS ID, and then read the state or the plan to discover which arguments to write. That command still works and is still documented, but it is now the least preferred route: it writes state with no plan, it cannot generate configuration, and it handles one object per run. The configuration-driven blocks and the generated configuration replace that routine, as the official CLI page itself explains.

The Terraform Made Easy book, which uses a Google Cloud scenario, has no coverage of import blocks or of the refactoring blocks (according to the issue’s chapter mapping), so a reader who follows it only meets the older, one object at a time route. Its examples were also written for older provider versions, so check the argument names and import formats of the Google provider on the registry. The Terraform in Depth book (2025) covers import blocks, configuration generation and bulk import, and is the better match for those parts. Everything that appeared in Terraform 1.14 and later, which means list resources, .tfquery.hcl files, the terraform query command, imports inside modules (1.16) and policy evaluation of query results (1.17), is newer than both books, so for those the official documentation is the source.

Pitfalls

  • Importing the same object twice. Two addresses bound to one remote object break the one-to-one assumption, and Terraform may then behave unexpectedly. Check terraform state list and terraform state identities -json before importing, and in HCP Terraform look at the IaC status column.

  • An imported object without a resource block. Anything in state that has no configuration is planned for destruction on the next run, so write or generate the block before the first apply that follows an import.

  • Importing with a half-empty block and applying the update. The omitted arguments are reset to defaults. Read the plan after the import and fix the block before apply makes the object match it.

  • Unknown ids. The id must be known at plan time. Ids that are outputs of resources created in the same run cannot be used, and a failed terraform plan is the only symptom.

  • Wrong index in to. For a resource with for_each, aws_s3_bucket.covers without the key does not match any instance. Use aws_s3_bucket.covers["prod"].

  • Reusing a generated file name. Both plan -generate-config-out and query -generate-config-out fail if the path exists. Delete the previous file, or use a unique name in CI.

  • Treating generated code as final. It is a template. Check for defaults, computed values, literals that should be references, and secrets.

  • limit too low. A query stops at 100 results per list block by default and does not warn that more exist.

  • Credentials in the wrong place. The provider used for the import needs read access, and only that. Use the same short-lived credentials (environment variables, OIDC) as the rest of the pipeline, never keys in .tfquery.hcl or in a committed provider block.

  • Expecting terraform import to run remotely. With HCP Terraform it runs locally and lacks the remote variables. Use an import block so the run happens in the workspace.

  • Importing something that changes under you. Resources managed by another system (an autoscaling group, a Kubernetes controller) keep being modified. Use ignore_changes or, better, do not adopt what another controller owns.

References