Terraform Professional: Importing Infrastructure and Managing Its Lifecycle
Importing infrastructure in Terraform means binding objects that already exist, created by hand or by another tool, to resource addresses in your configuration and state so that Terraform manages them from then on; managing the lifecycle means controlling how Terraform replaces, protects, refactors and acts on those resources afterwards. At the professional level this covers reviewable import blocks versus the terraform import command, exact import addresses for modules and for_each, plan-time import IDs and resource identity, generated configuration, bulk discovery with terraform query, the lifecycle meta-argument (create_before_destroy, replace_triggered_by, and which settings must be literal), provider actions, and moved blocks for refactoring modules safely.
On this page10 sections
- How do you bring existing infrastructure under Terraform management?
- How do you write the import block's to address, including modules and for_each?
- What can the import ID be, and when do you use identity instead?
- How do you read an import plan, and what should it show?
- How does terraform plan -generate-config-out draft resource blocks?
- How do you discover and import resources in bulk with terraform query?
- How does the lifecycle block control replacement, and which values must be literal?
- How does replace_triggered_by force a replacement?
- How do action_trigger and -invoke run provider actions?
- How do moved blocks refactor module calls without state commands?
- Choose between import blocks and the terraform import command when imports must be reviewed in a plan before state changes
- Write correct import to addresses for modules, count, for_each and module instances, and import many objects with for_each
- Supply an import id that is known at plan time, or a resource identity, and read an import plan until it shows only the import
- Generate draft configuration with terraform plan -generate-config-out, choose the right provider for an import, and discover resources in bulk with list blocks and terraform query
- Control replacement with create_before_destroy and replace_triggered_by, and know which lifecycle arguments accept only literal values
- Run provider actions with action_trigger or -invoke, and refactor module calls with moved blocks instead of state commands
How do you bring existing infrastructure under Terraform management?
You bring an existing object under management by importing it: Terraform binds the real object to a resource address in state, so later plans manage it like anything Terraform created. Since Terraform 1.5 the preferred way is a declarative import block in the root module; the older terraform import command still exists for one-off use.
import {
to = aws_security_group.web
id = "sg-0a1b2c3d4e5f"
}
resource "aws_security_group" "web" {
name = "web"
vpc_id = var.vpc_id
}
The two methods differ in when state changes, and that difference decides most production scenarios:
| Aspect | import block | terraform import command |
|---|---|---|
| When state is written | Only when the plan containing the import is applied | Immediately, when the command runs, with no plan |
| Reviewable | Yes: the import appears in terraform plan, in a pull request and in HCP Terraform runs | No: nothing to review before state changes |
| How many objects | Many per run, including for_each imports | One per command |
| Configuration generation | Yes, with terraform plan -generate-config-out | No; the resource block must already exist |
| Works in VCS-driven HCP Terraform workspaces | Yes, through the normal plan and apply | Only by running it locally against the workspace's state |
The command's immediacy is the classic shared-state trap. If an engineer imports into a shared remote state from an unmerged branch, the object is in state at once, but the main branch has no resource block for it. Anyone who plans main next sees an object in state with no configuration and gets a proposal to destroy it. With import blocks the state change and the code arrive together in one reviewed apply, so this cannot happen.
An import block whose import has already been applied is harmless. On later plans Terraform sees that the address is already in state with that object and plans nothing for it, so you can keep applied import blocks as a record or delete them. Importing is not the right tool for objects you only need to read: if another team owns a VPC and you only need its ID and CIDR, use a data source. Importing makes your configuration the owner, so a later change or terraform destroy in your configuration would act on their object.
How do you write the import block's to address, including modules and for_each?
The to argument is the full address of a managed resource instance in your configuration, written exactly as Terraform addresses it in state. Import blocks are only allowed in the root module, so when the resource lives in a child module the address starts with the module path. Data sources cannot be imported (only managed resources can), and each resource instance address can have only one import block.
| Where the resource is declared | to address |
|---|---|
| Root module, single resource | aws_vpc.main |
Root module, count | aws_nat_gateway.az[1] (zero-based, unquoted number; the second instance is [1]) |
Root module, for_each | aws_s3_bucket.store["media"] (quoted key) |
Inside module "network" | module.network.aws_vpc.this |
Inside a module call that uses for_each | module.region["eu"].aws_lb.this (the key goes on the module call, the thing that has for_each) |
An import block can itself use for_each to import many instances at once. Inside it, each.key and each.value come from the import block's own collection, and the to expression must produce the resource's instance keys exactly:
locals {
buckets = { media = "corp-media-store", backups = "corp-backup-store" }
}
resource "aws_s3_bucket" "store" {
for_each = local.buckets
bucket = each.value
}
import {
for_each = local.buckets
to = aws_s3_bucket.store[each.key] # resource key
id = each.value # real bucket name
}
Iterating the same map as the resource is the simplest way to keep keys aligned. When the resource derives its keys with an expression (for example trimsuffix(n, "-store") over a set of full names) and the import block iterates the raw set, you must apply the same expression in to, such as aws_s3_bucket.store[trimsuffix(each.value, "-store")]. If the computed key matches no declared instance, the plan fails because the import target does not exist in configuration. Import blocks do not support count; use for_each.
What can the import ID be, and when do you use identity instead?
The id argument is the provider's import ID for the object, and it must be known at plan time, because Terraform looks the object up while planning. Its format is resource-specific and documented on each resource's page in the provider docs: a bucket name, an instance ID, or a composite such as <role-name>/<policy-arn>.
Since Terraform 1.6 id accepts expressions, not only literal strings. What decides whether an expression works is whether its value is known during plan:
| Known at plan time (usable) | Unknown until apply (not usable) |
|---|---|
| Input variables and locals built from them | Attributes of resources created in the same run (for example a role named with name_prefix, whose final name the provider computes at creation) |
| Attributes of data sources whose own arguments are known, because Terraform reads them during plan | Module outputs that pass such a value through |
| Literal strings and string templates of known values | The output of a terraform_data resource being created in this run |
If the object you need depends on something not yet created, create it in one apply and import in a later one, or supply the value as a variable.
From Terraform 1.12, providers can define a resource identity: a structured set of attributes that uniquely names an object, such as a bucket name plus Region. An import block can locate the object with an identity map instead of an id string. The two are mutually exclusive: set exactly one, never both, even if they describe the same object. Identity belongs on the import block only; it is not an argument of the resource block. It helps when the provider's ID string is awkward or ambiguous, and it is what bulk discovery with terraform query returns.
import {
to = aws_s3_bucket.reports
identity = {
bucket = "corp-finance-reports"
region = "eu-west-1"
}
}How do you read an import plan, and what should it show?
A safe import plan shows only the import: the summary reads like Plan: 1 to import, 0 to add, 0 to change, 0 to destroy. Terraform plans the import and then compares your resource block with the object it just read, so any difference becomes an additional action on the real object in the same apply.
| Plan shows | What it means | What to do |
|---|---|---|
| Import only | The configuration matches the real object | Apply |
Import plus update in place (~) | Some arguments in the resource block differ from reality | Edit the block to match, re-plan, unless the change is intended |
Import plus replacement (-/+) | An argument that forces replacement (an identifier, name or engine) differs | Correct that argument and re-plan; applying would destroy the object you meant to adopt |
The fix is always in the configuration. Lifecycle flags do not rescue a mismatched import: prevent_destroy only makes a replacement plan fail, create_before_destroy still replaces the object, and a permanent ignore_changes = all hides the drift but leaves Terraform no longer managing the resource's arguments. Switching to the terraform import command does not help either: it writes state without the comparison, and the very next plan proposes the same update. Iterate on the resource block until the plan is import-only, then apply.
How does terraform plan -generate-config-out draft resource blocks?
When you have import blocks but no resource blocks yet, terraform plan -generate-config-out=generated.tf asks Terraform to read each target object and write a first-draft resource block for every import whose to address has no configuration. It is a plan option only; apply, init and the terraform import command do not take it.
- New file only. Terraform refuses to run if the path already exists; it never appends or overwrites. For a second batch, point it at a new file name. Blocks already written to the first file count as configuration, so only the new targets are generated.
- Resource blocks only. The file holds resource blocks; your import blocks stay where you wrote them and are still needed for the import itself.
- Literal values. Generated arguments are the values the provider reported, so a subnet's
vpc_idis a hard-coded ID. Replace literals with references and variables where the codebase expects them. - Possible conflicts. A resource can accept mutually exclusive arguments, and generation may write both, so the next plan fails until you remove one.
- Written at plan time. Review and edit the file, re-plan until the plan is import-only, then apply.
Which provider performs the import? Normally the provider configuration of the target resource: whatever the resource block's provider argument selects, or, for a resource in a child module, whatever the caller passes through the module's providers map. The import block's own provider argument exists for one case only: when Terraform is generating the resource block and therefore has no block to take the provider from. If a resource block already exists, a provider argument on the import block fails validation ("Invalid import provider argument"), even if both name the same alias.
| Situation | Where to choose the aliased provider |
|---|---|
| Resource block already written | provider = aws.eu on the resource block (or providers = { aws = aws.eu } on the module call); none on the import block |
| No resource block; generating it | provider = aws.eu on the import block, then terraform plan -generate-config-out |
Without the argument during generation, Terraform uses the default provider configuration, which may point at the wrong Region and fail to find the object. Provider aliases never appear inside a to address.
How do you discover and import resources in bulk with terraform query?
When you do not know the IDs of the objects to adopt, Terraform search finds them for you: list blocks in files ending .tfquery.hcl describe what to search for, and terraform query runs them against the provider (Terraform 1.14 and later, with providers that implement list resources).
# discover.tfquery.hcl
list "aws_instance" "analytics" {
provider = aws
limit = 500
config {
filter {
name = "tag:team"
values = ["analytics"]
}
}
}
terraform querylists the matching objects with each one's identity and a display name; by default it does not return their full attributes.terraform query -generate-config-out=found.tfwrites both import blocks and resource blocks for the results to a new file, which you review, move into the configuration and apply through a normal plan. Contrastterraform plan -generate-config-out, which writes only resource blocks for import blocks you already wrote.- A list block returns at most 100 results by default; a result count of exactly 100 is the clue. Raise the
limitargument to fetch more. - Results carry identities only unless
include_resource = true, which adds each object's full attributes (for exampleinstance_type) for use in expressions, at some cost in query time. - List blocks live only in
.tfquery.hclfiles; they are not valid in ordinary.tffiles andterraform plandoes not run them. Flags such as-parallelismor-refresh=falsedo not change the limit or what each result contains.
A data source that lists instances is not a substitute: it reads values for your configuration but does not create import or resource blocks.
How does the lifecycle block control replacement, and which values must be literal?
The lifecycle meta-argument block changes how Terraform creates, updates, replaces and destroys a resource. Its arguments include create_before_destroy, prevent_destroy, ignore_changes, replace_triggered_by, precondition/postcondition and action_trigger (and a destroy argument used inside removed blocks). Terraform processes create_before_destroy, prevent_destroy and ignore_changes before it evaluates expressions, so those three accept only literal values: prevent_destroy = var.protect, prevent_destroy = terraform.workspace == "prod" or ignore_changes = local.ignored all fail validation, whether or not the variable has a default. The others work differently: replace_triggered_by takes references to managed resources (including each.key or count.index, but no variables or locals), precondition/postcondition take ordinary expressions as their conditions, and action_trigger names actions and events.
create_before_destroy. When an argument change forces replacement, Terraform by default destroys the old object first and then creates the new one, leaving a gap. With create_before_destroy = true it creates the replacement first, switches dependants to it, then destroys the old object. Things to know:
- Unique names collide. If the provider requires a unique name (an IAM role, a security group in a VPC), the new object cannot be created while the old one still holds the name. Let the provider generate names with
name_prefixor add a random suffix. - It propagates to dependencies. If a resource with
create_before_destroydepends on another resource, Terraform applies create-first to that dependency's replacements too and records this in state. It spreads to what the resource depends on, not to the resources that depend on it. Explicitly settingcreate_before_destroy = falseon such a dependency produces a cycle error. - Destroy-time provisioners do not run on a resource that uses
create_before_destroy. - It applies to every replacement of the resource, whatever triggered it.
ignore_changes takes a list of attribute references written literally, and can name individual map elements, so ignore_changes = [tags["Department"], tags["BackupPolicy"]] lets an external tool manage two tag keys while Terraform keeps managing every other tag. [tags] would ignore all of them, and a for expression is not allowed. (Using ignore_changes to live with drift is covered in the state and drift lesson; prevent_destroy and conditions are covered in the configuration lesson.)
Settings that differ per environment. Because dynamic blocks cannot generate meta-argument blocks such as lifecycle, the supported pattern for, say, destroy protection in production only is two resource blocks with literal settings, each switched on or off with count:
resource "aws_db_instance" "protected" {
count = var.environment == "prod" ? 1 : 0
# ...
lifecycle { prevent_destroy = true }
}
resource "aws_db_instance" "unprotected" {
count = var.environment == "prod" ? 0 : 1
# ...
}How does replace_triggered_by force a replacement?
replace_triggered_by replaces a resource when something it references changes, even if none of the resource's own arguments changed. It accepts references to managed resources, their instances or their attributes, and nothing else: input variables, locals and data sources are rejected.
| Reference | Replaces this resource when |
|---|---|
A whole resource, aws_ssm_parameter.ami_id | That resource is planned for any update or replacement, including a tag-only edit |
An attribute, aws_ssm_parameter.ami_id.version | That attribute's value changes |
One instance, aws_s3_object.settings[each.key] | The matching instance changes (inside a for_each resource) |
A whole for_each resource, aws_s3_object.settings | Any instance changes, so every instance of this resource is replaced |
Inside a resource that uses for_each or count, you can use each.key, each.value or count.index in the reference, so each instance follows only its own partner.
To replace on a plain value, such as a release version held in a variable, wrap it in a terraform_data resource. Its input changes when the variable changes, which is a resource change the trigger can see:
resource "terraform_data" "release" {
input = var.release
}
resource "aws_instance" "app" {
# ...
lifecycle {
replace_triggered_by = [terraform_data.release]
}
}
depends_on only orders operations; it never triggers a replacement.
How do action_trigger and -invoke run provider actions?
Actions are provider-defined operations, declared with an action block, that do something outside the normal create-update-delete cycle, such as invoking a Lambda function or invalidating a CDN cache. You run them in two ways: automatically on resource events with an action_trigger block inside a resource's lifecycle, or manually with the -invoke option.
resource "aws_ecs_service" "api" {
# ...
lifecycle {
action_trigger {
events = [after_create, after_update]
actions = [action.aws_lambda_invoke.notify_deploy]
}
}
}
eventslists the resource events that fire the actions;before_create,after_create,before_updateandafter_updateare the core ones. To cover creation and in-place updates you need an event from each pair.actionslists the actions to run, in order.- An update event fires whenever apply updates the resource, including an apply that reverts drift with no code change, not only when its code changed in version control.
terraform plan -invoke=action.<type>.<name>orterraform apply -invoke=…runs that one action on its own. All other configuration is excluded from that run, so pending resource changes stay unapplied and nothing the action references is changed.- A trigger needs no CLI flag.
replace_triggered_byand postconditions do not run actions; they replace resources and check assumptions respectively.
How do moved blocks refactor module calls without state commands?
A moved block tells Terraform that an object already in state now lives at a new address, so the plan shows a move instead of a destroy and a create. Because it is configuration, the move is reviewed in the plan and applied by the normal workflow, which is why it is preferred over terraform state mv wherever state commands are restricted.
| Refactor | moved block(s) |
|---|---|
| Rename a module call | One block, moved { from = module.billing, to = module.invoicing }; it moves every resource inside the module |
Module call from count to for_each | One block per instance: module.worker[0] to module.worker["east"], module.worker[1] to module.worker["west"] |
| Rename a resource inside a shared module | In the module itself, with addresses relative to the module: moved { from = aws_s3_bucket.main, to = aws_s3_bucket.this } |
A module may make moved statements only about its own resources and its child modules, and it cannot refer to the address its caller gives it. That is what lets a module author ship a rename in a new version: every caller that upgrades gets a move in their plan with no edits of their own. Writing the move in each caller works but needs every caller to change. A removed block is not a substitute: by default it destroys the object, and re-importing under the new address is a two-step process that moved blocks make unnecessary. Moving single resources inside one configuration, and removed blocks, are covered in the state and drift lesson.
Tip. Expect production scenarios decided by a stated constraint: imports must appear in a reviewed plan before state changes, nothing may be destroyed or recreated, the team must not own another team's object, state commands are forbidden, callers may only bump a module version, or protection must apply in production only. Many items show a short block of HCL and ask for the exact import address, id expression, lifecycle content or moved block, so practise writing addresses for modules, count and for_each. Be clear on how neighbouring features differ: removed versus moved blocks, data sources versus imports, depends_on versus replace_triggered_by, and plan versus query configuration generation. Some items ask you to select two correct statements.
- Import blocks change state only when their reviewed plan is applied; terraform import writes state immediately with no plan, which can leave shared state ahead of the merged code.
- The to address must match a declared managed resource instance exactly: module.<call>.<type>.<name>, [n] for count, ["key"] for for_each, and the key on whichever block has for_each. Data sources cannot be imported.
- A for_each import block must reproduce the resource's own instance keys; iterating the same map as the resource is the simplest way.
- The import id must be known at plan time: variables, locals and data sources work; attributes of resources created in the same run do not. id and identity are mutually exclusive.
- A safe import plan is import-only. An extra update or replacement means the resource block differs from reality: fix the block, not the lifecycle settings.
- terraform plan -generate-config-out writes resource blocks with literal values to a new file only; the import block's provider argument is allowed only when generating configuration.
- terraform query with list blocks in .tfquery.hcl discovers unknown objects; limit defaults to 100 and include_resource adds attributes; -generate-config-out writes import and resource blocks.
- create_before_destroy, prevent_destroy and ignore_changes take literal values only; for per-environment settings use two count-toggled resource blocks. replace_triggered_by takes managed-resource references, and conditions take expressions.
- create_before_destroy needs unique-name handling, spreads to the resources it depends on and stops destroy provisioners; replace_triggered_by takes managed resources or attributes, with terraform_data for plain values and each.key for per-instance scope.
- action_trigger runs provider actions on create and update events, drift fixes included; -invoke runs one action alone; moved blocks refactor module calls, and a shared module ships its own moves.
Frequently asked questions
Should I use import blocks or the terraform import command?
Use import blocks for almost all work. They put the import into terraform plan so it can be reviewed, they import many objects in one run, they work in VCS-driven HCP Terraform workspaces and they can generate configuration. The terraform import command writes to state immediately with no plan, so it suits only one-off local work where no review is required and the matching resource block is committed at the same time.
Can I delete import blocks after the import has been applied?
Yes. Once an import has been applied, later plans see that the object is already in state at that address and plan nothing for the import block, so leaving it is harmless. Teams either keep applied import blocks as a record of what was adopted or remove them in a follow-up change.
Why does my Terraform import plan also show an update or a replacement?
Because the resource block does not match the real object. Terraform imports the object and then compares it with your configuration, so every difference becomes an update, and a difference in an argument that forces replacement becomes a destroy-and-create of the object you meant to adopt. Edit the resource block to match reality and re-plan until the plan shows only the import.
Can a Terraform import ID refer to another resource?
Only if its value is known at plan time. Input variables, locals and data source attributes are fine because Terraform knows them while planning. Attributes of resources being created in the same run, including module outputs that pass them through, are unknown until apply, so the import cannot be planned. Create the dependency in an earlier apply or supply the value another way.
What is the difference between terraform plan -generate-config-out and terraform query -generate-config-out?
terraform plan -generate-config-out drafts resource blocks for import blocks you have already written, when those addresses have no configuration. terraform query -generate-config-out runs list blocks from .tfquery.hcl files to discover objects you do not have IDs for, and writes both the import blocks and the resource blocks. Both write to a new file only and produce a draft to review.
Why can't I set prevent_destroy from a variable in Terraform?
Terraform processes create_before_destroy, prevent_destroy and ignore_changes before it evaluates expressions, so those settings must be literal values, and a variable, local, workspace comparison or for expression fails validation. To protect a resource in one environment only, declare two resource blocks with literal lifecycle settings and switch between them with count.
How do I replace a Terraform resource when a variable changes?
replace_triggered_by cannot reference a variable directly, because it accepts only managed resources and their attributes. Create a terraform_data resource with input set to the variable and reference that resource in replace_triggered_by. When the variable changes, the terraform_data resource changes and triggers the replacement.
Does create_before_destroy affect other resources?
Yes. When a resource with create_before_destroy depends on other resources, Terraform applies create-first replacement to those dependencies as well and records it in state; setting create_before_destroy = false on such a dependency causes a cycle error. The setting also stops destroy-time provisioners on the resource from running.
Source
This lesson covers the "Infrastructure lifecycle and operations" domain of the official Terraform Pro 002 exam guide. Vendors revise their guides — check the source for the current version.
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.