TL;DR
Terraform import brings a resource that already exists in the cloud into Terraform state without changing the resource itself. The classic command writes only state, so the matching configuration stays manual, and a mismatch can appear in the next plan as an unexpected change or even a replacement. Import blocks and generated configuration reduced that manual work, but import still needs a resource ID that someone already has. This guide walks through the classic command, import blocks, batch imports, common errors, and how SGCode handles discovery.
Cloud accounts rarely stay as tidy as the code written for them. Engineers open console tabs during incidents, acquired companies arrive with undocumented accounts, and one-off scripts leave resources behind long after the people who ran them have moved on. Terraform manages only what its state file knows about, so each of these resources sits outside every plan until someone brings it in.
Terraform import is the built-in way to close that gap, and it has changed noticeably since the first command-line version. This guide follows one orphaned security group through the classic command, import blocks, generated configuration and batch imports, then looks at what happens when the problem is finding resources rather than importing them. By the end, you will know how each form of import works, where each one leaves work for a person, and when discovery has to come before import.
What Terraform Import Does for a Security Group Made During an Incident
Terraform import records an existing cloud resource in the state file so that Terraform can manage it from then on. Suppose we have a platform team at a mid-size fintech. During an incident, an engineer created a security group in the console to open database access, and nobody wrote the matching Terraform afterward. The security group works, but Terraform has no record of it.
Why Terraform State Needs a Record of Every Resource
Terraform state is the map from each resource block in configuration to an object in the cloud. Every plan compares that map with both the configuration and the live resources. A resource with no entry in state does not appear in plans and is not checked for drift. If someone later writes a resource block for it, Terraform plans to create a second copy instead of recognizing the original.
What Terraform Import Changes and What It Leaves Alone
Terraform import adds one entry to state that links a resource address to the cloud provider's resource ID. It does not create, modify or destroy the resource in the cloud. Only Terraform's own record changes. Import solves one problem: getting a resource's identity into state, and it was never designed to write the resource's configuration.
How the Classic Terraform Import Command Works on One Resource
The classic import workflow is a single CLI command that takes a resource address and a resource ID. For the fintech team, the address is the name they want in configuration and the ID is the one AWS assigned to the orphaned security group.
Running the Command Against the Orphaned Security Group
The command links the address aws_security_group.web to the AWS security group ID:
| terraform import aws_security_group.web sg-0a1b2c3d4e5f67890 |
|---|
The command also expects a resource block to exist in configuration before it runs, even as an empty placeholder. Terraform needs somewhere to attach the imported state, and the person running the import fills in the attributes afterward.
| resource "aws_security_group" "web" { # Terraform needs somewhere to attach the imported state. # This gets filled in manually after the import runs. } |
|---|
Why the Next Plan Shows So Many Changes
A plan run right after the import shows a long list of changes, because the empty block lists none of the security group's settings. Nothing is wrong with the resource; the configuration hasn't described its settings yet. The engineer inspects the resource with terraform state show aws_security_group.web or the console and writes each attribute into the block by hand.
Until the block matches, state and configuration disagree. A wrong CIDR block, a missed tag or an attribute left at its default value appears as an unexpected change in the plan. A missed tag does not force replacement, but an attribute that does, such as an AMI ID or an availability zone, turns the same mismatch into a destroy-and-recreate.
Writing Addresses for Modules, Counted Resources and for_each
Production configurations rarely stay flat, so the address has to carry the module path, the index or the map key. Square brackets have special meaning in most shells, which is why the address for a counted or for_each resource needs quotes.
# Resource inside a child module
terraform import module.network.aws_vpc.main vpc-0f1e2d3c4b5a69788
# Resource created with count
terraform import 'aws_instance.web[0]' i-0abcd1234efgh5678
# Resource created with for_each
terraform import 'aws_instance.web["primary"]' i-0abcd1234efgh5678
How Import Blocks and Generated Configuration Changed the Workflow
Terraform 1.5 introduced the import block, which moves import from a one-off command into configuration that a team can review in a pull request. Paired with a flag that drafts configuration, it changed the job from writing everything by hand to correcting a first draft.
Writing an Import Block Instead of Running a Command
An import block names the target address and the resource ID in a .tf file:
import {
to = aws_security_group.web
id = "sg-0a1b2c3d4e5f67890"
}
Terraform does not run this block immediately. It evaluates the block during the next plan, so the intent to import sits in version control where a reviewer can see it before anything is applied.
Generating a First Draft of the Configuration
Running a plan with the -generate-config-out flag asks Terraform to draft the resource block as well:
terraform plan -generate-config-out=generated.tf
Terraform writes the attributes it finds on the existing security group into generated.tf, so nobody has to transcribe them from console screens. The plan command page labels the flag experimental and notes that the output file must not already exist. A HashiCorp maintainer on the Terraform forum has also said generation works for the root module, so an import block that targets a resource inside a child module may still need its configuration written by hand.
Why an S3 Bucket Needs Several Import Blocks
Configuration generation fills in attribute values, but it does not find related resources. An S3 bucket with versioning and server-side encryption is three resources in Terraform, because versioning and encryption are modeled as separate resources tied to the bucket rather than attributes of it. Each one needs its own import block with its own ID.
import {
to = aws_s3_bucket.assets
id = "prod-assets-bucket"
}
import {
to = aws_s3_bucket_versioning.assets
id = "prod-assets-bucket"
}
import {
to = aws_s3_bucket_server_side_encryption_configuration.assets
id = "prod-assets-bucket"
}
If a block for a sub-resource is left out, that sub-resource stays unmanaged. Someone still has to know the sub-resources exist before they can write the blocks.
Importing Many Resources With for_each on the Import Block
Terraform 1.7 added a for_each argument to the import block, so one block can describe a batch of imports instead of one block per resource:
locals {
existing_security_groups = {
"web" = "sg-0a1b2c3d4e5f67890"
"db" = "sg-1b2c3d4e5f678901a"
}
}
import {
for_each = local.existing_security_groups
to = aws_security_group.this[each.key]
id = each.value
}
This narrows the one-resource-at-a-time limit without removing it. The team still has to know every ID and build the map before the block runs, so for_each batches a known list and does not discover an unknown one.
Where Terraform Import Still Falls Short for Larger Estates
The import block and configuration generation improved the workflow, yet several limits carry over from the classic command. Four of them decide whether import is enough for a given estate:
-
Import needs a resource ID that someone already has. Both the classic command and the import block take an address and an ID, and neither searches an account. Terraform 1.14 added
terraform query, which can find resources outside state, but each query targets a single provider and depends on provider support. - Generated configuration needs review. Provider support for config generation varies. Some attributes may come back blank, commented out, or set to a value that does not reflect the resource. The usual culprits are sensitive values, computed fields and settings that only one provider supports.
- Each block targets one resource in one account. A brownfield estate with years of console-created infrastructure across several accounts becomes a long list of import blocks, and each block needs an ID lookup first.
- A mismatched import fails quietly. It produces a plan that looks like a routine update or replacement, which is easy to approve when the reviewer doesn't know the resource was just imported.
Three Errors Worth Recognizing Before They Slow Down a Review
A few errors appear often enough during import work that they are worth recognizing on sight instead of treating each as a new puzzle:
- Resource already managed by Terraform. The target address already has a state entry, and Terraform will not overwrite it. The resource was usually imported earlier, or the address collides with another declaration.
-
A plan that proposes destroy and recreate right after import. This points to a configuration mismatch on an attribute that forces replacement, such as an AMI ID or an availability zone. Running
terraform planimmediately after each import, before touching anything else, catches it before an apply. -
Reaching for a moved block when the resource needs an import. A
movedblock records a rename or relocation of a resource that is already in state, and it brings nothing new into state. Terraform skips a moved block whose source address is not in state, so the plan proposes creating a new resource, which is the duplicate the import was meant to prevent.
When One Import Block Is Enough and When It Is Not
Import in either form fits a narrow situation: a small number of known resources, a team that knows what needs importing and why, and time to review each block. The orphaned security group from the incident is a fair candidate for a direct import block. A handful of leftovers from a finished migration is another.
That scope breaks down as the estate grows. The harder problem starts before any import block exists: figuring out what is running in the accounts at all.
How SGCode Finds and Codifies Infrastructure That Import Cannot See
SGCode was built for the problem terraform import leaves open, which is discovering infrastructure that no one has recorded yet. Cloud Inventory connects to AWS, Azure and GCP accounts and keeps a real-time inventory of what it finds. The three parts below each answer one of the gaps from the limitations list.
Cloud Inventory Shows What Is Managed and What Is Not
Cloud Inventory assigns every discovered resource a status, and Unmanaged marks a resource that is not in any IaC state file. The other statuses are SG Managed, Externally Managed, SG Drifted, SG State Only, External State Only and Codifying. IaC Coverage appears at the top of the page as the share of discovered resources managed as code, which gives a platform team a number to improve instead of a list of IDs to remember.
For the fintech team, the question changes from which IDs they know about to which resources show as Unmanaged. Engineers open SGCode in the sidebar, choose Cloud Inventory, and use the Resource Discovered tab to tick the resources they want to codify.
Codification Produces Reviewed Code and a Pull Request
Choosing Codify opens a Code Workbench where the generated Terraform or OpenTofu code can be reviewed and edited. The generation accounts for the relationships between the selected resources, and dependencies can also be added by hand. That addresses the gap where import blocks handle one resource at a time and leave related resources to the engineer.
Before the code is delivered, SGCode runs an internal plan and regenerates the code if the plan finds errors. A Fix all issues action shows proposed fixes as a diff, and Run test plan checks policy results before anything is pushed. Create PR & Plan then publishes the result as a pull request and runs a plan. SGCode does not merge the pull request, so the merge happens in the Git provider and the usual review still applies.
Attached Policies Apply to Codified Infrastructure Runs
Once the codified configuration is merged into a Workflow that has Policy Sets attached, the attached policies apply to the resulting runs the same way they apply to any other change. That holds for policies written in Tirith and for policies written in OPA. A Policy Set can be scoped to the organization, a Workflow Group or a connector.
Brownfield resources can easily predate an organization's current security baseline, so applying the same policies to them closes a gap that import alone leaves open. This Tirith policy checks that S3 server-side encryption uses KMS:
{
"meta": {
"required_provider": "stackguardian/terraform_plan",
"version": "v1"
},
"evaluators": [
{
"id": "s3_sse_must_use_kms",
"description": "Newly codified S3 buckets must use KMS server-side encryption",
"provider_args": {
"operation_type": "attribute",
"terraform_resource_type": "aws_s3_bucket_server_side_encryption_configuration",
"terraform_resource_attribute": "rule.0.apply_server_side_encryption_by_default.0.sse_algorithm"
},
"condition": {
"type": "Equals",
"value": "aws:kms",
"error_tolerance": 1
}
}
],
"eval_expression": "s3_sse_must_use_kms"
}
This rule checks encryption resources that exist in the plan. A bucket with no encryption resource at all would not trigger it, so a real baseline pairs it with a second check that every aws_s3_bucket has a matching encryption configuration.
Using Terraform Import and SGCode Together
Terraform import and SGCode suit different starting points of the same problem. A team that knows about a few orphaned resources, such as the security group from the incident, can write an import block and review the generated configuration directly. A team facing an acquired account with no documentation needs the discovery step first.
A workable pattern is to let SGCode discover and codify the large unknown estate first, opening pull requests as it works through the backlog. Scoped import blocks then handle the occasional one-off resource that appears later, such as an orphaned object found in a routine audit, where the team already knows what it is dealing with.
Where Should You Start
Terraform import, in the classic and block forms, solves the narrow problem of writing a resource's identity into state, and generated configuration has cut the transcription work that used to follow. It still needs a resource ID that someone already has, and each block targets one resource. For the fintech team, a single orphaned security group fits that model well, while an estate of undocumented infrastructure needs discovery first.
Begin with the resources you already know about, and run terraform plan after each import to confirm the configuration matches. Then connect your cloud accounts and see how SGCode discovers and codifies unmanaged infrastructure, starting from the IaC Coverage figure in Cloud Inventory. For background on the problem itself, read what brownfield infrastructure looks like in practice.
Frequently Asked Questions
What does terraform import change in the state file?
It adds one entry that links a resource address in configuration to the resource ID in the cloud. The cloud resource itself is not created, modified or destroyed. Only Terraform's record of what it manages changes.
Does terraform import write the Terraform configuration for me?
The classic command does not. It needs a resource block to exist already and leaves the attributes to the person running it. Import blocks with -generate-config-out, available from Terraform 1.5, draft a configuration file, but the draft needs review and the flag is still labelled experimental.
What is the difference between terraform import and an import block?
The classic command runs immediately and changes only state. An import block is declared in configuration, evaluated during a plan, and can be paired with configuration generation. That makes it reviewable in a pull request and repeatable, which the CLI command is not.
Can terraform import find resources that are not in state?
No. Both forms need the resource address and ID before anything runs. Terraform 1.14 added terraform query for provider-limited discovery, and SGCode's Cloud Inventory approaches the same problem across connected AWS, Azure and GCP accounts.
Is it safe to run terraform import on production resources?
The import itself leaves the cloud resource unchanged. The risk comes afterward: a configuration that doesn't match the resource shows up in the next plan as a change, and an attribute that forces replacement shows up as a destroy-and-recreate. Reviewing the plan right after each import prevents that.
How does a moved block differ from an import block?
A moved block records that a resource already in state has a new name or location in configuration. An import block adds a resource that has no state entry yet. Using a moved block for an unimported resource raises no error, and the plan proposes creating a new resource instead.
Does SGCode merge the pull requests it creates?
No. SGCode publishes the generated code as a pull request and runs a plan, and the merge happens in your Git provider. That keeps your normal review process in place.



Top comments (0)