Importing Existing Infrastructure into Terraform: import blocks, moved, and removed

Quick answer
Adopting Terraform over already-running infrastructure is the messiest job in IaC. Here's how the declarative import block, config generation, and the moved and removed blocks replace the old error-prone terraform import CLI — and how to reach a clean no-op plan.
- The old way: terraform import on the CLI
- The modern way: the import block
- Refactor safely with the moved block
- Drop from state with the removed block
- The four tools at a glance
13 min read · DevOps & Platform
Almost nobody starts with Terraform. You start with a console, a few aws cli scripts, some ClickOps, and eighteen months later you have a production estate that Terraform has never touched. Then someone decides "everything should be in code," and the real work begins: getting Terraform to adopt infrastructure that already exists, without deleting and recreating it in the process.
This is the single most error-prone part of infrastructure as code, because the goal is subtle. You're not trying to create anything. You're trying to describe reality so precisely that terraform plan says "No changes." Anything less than a clean no-op plan means Terraform disagrees with reality, and applying that disagreement is how people accidentally destroy running databases.
This post walks through the modern import workflow — the declarative import block, config generation, and the moved and removed blocks — and the migration process that gets you to that clean plan. Everything here works in both Terraform (1.7+) and OpenTofu (1.7+) unless I call out a version difference.
The old way: terraform import on the CLI
For years, the only way to bring an existing resource under management was the imperative CLI command:
# First, you write the resource block by hand (empty-ish):
# resource "aws_instance" "api" {}
# Then you tell Terraform which real object it maps to:
terraform import aws_instance.api i-0abcd1234ef567890This does exactly one thing: it writes a state entry linking the address aws_instance.api to the real object i-0abcd1234ef567890. It does not write any HCL for you. You have to author the resource block yourself, by hand, matching every attribute the real object already has, and then iterate terraform plan until the diff is empty.
Why it hurts at scale:
- It's imperative and stateful. The import happens the moment you run the command — it's not in your plan, not in code review, not repeatable. If you re-run your bootstrap, you get "resource already managed" errors.
- One resource at a time. Importing a VPC with subnets, route tables, NAT gateways, security groups, and a hundred rules means a hundred hand-typed commands, each with a resource-type-specific ID format you have to look up.
- No config generation. You reverse-engineer the HCL yourself. Miss a
tagsblock or a non-default attribute and the plan shows a spurious change; over-specify and you fight provider defaults. - Easy to corrupt state. Fat-finger the address and you've mapped a resource block to the wrong object. There's no dry run.
It still works, and it's occasionally handy for a quick one-off. But for any real migration it's the wrong tool.
The modern way: the import block
Terraform 1.5 (and OpenTofu 1.6) introduced a declarative import block. Instead of a side-effecting command, you write an import declaration into your configuration and it becomes part of the plan — reviewable, diffable, and applied atomically with everything else.
1import {
2 to = aws_instance.api
3 id = "i-0abcd1234ef567890"
4}
5
6resource "aws_instance" "api" {
7 # you still need this block — but see config generation below
8 ami = "ami-0c55b159cbfafe1f0"
9 instance_type = "t3.medium"
10}Now terraform plan shows you exactly what will happen: 1 to import, plus any diff between the real object and your HCL. Nothing is written to state until you terraform apply. Because the block lives in your repo, the import goes through the same pull request as everything else, and a teammate can see it before it touches state.
Generating config with -generate-config-out
The best part is that you no longer have to hand-write the resource block. Point an import block at a real object with no matching resource block, and Terraform will scaffold the HCL for you:
# imports.tf — just the import block, no resource block yet
import {
to = aws_instance.api
id = "i-0abcd1234ef567890"
}terraform plan -generate-config-out=generated.tfTerraform reads the live object and writes a resource "aws_instance" "api" block into generated.tf with the attributes it found. You then move that block into a sensible file, review it, and clean it up.
Be honest about the generated output — it is a starting point, not a finished module:
- Every attribute is dumped, including provider defaults. You'll get
monitoring = false, emptyebs_block_deviceshells, and computed-looking noise. Delete what you don't need to declare. - Sensitive values are not populated. Passwords, secrets, and other sensitive attributes come out blank or as placeholders — you must wire them to variables or a secrets store yourself.
- References are literals. The generated
subnet_idis a hardcoded"subnet-0abc..."string, notaws_subnet.private.id. If you want real dependencies (and you do), you replace those literals with references by hand. - No
depends_on, no lifecycle blocks, nofor_each. Anything structural is your job. The generator only knows attributes, not intent.
Treat the output like a machine-generated first draft: correct on values, naive on structure.
IDs differ per resource type
The id in an import block is whatever that resource's provider expects for import — and it is not consistent across resource types. You have to check the provider docs' "Import" section for each one. A few AWS examples:
| Resource | Import ID format | Example |
|---|---|---|
aws_instance | The instance ID | i-0abcd1234ef567890 |
aws_s3_bucket | The bucket name | my-app-prod-assets |
aws_iam_role | The role name | platform-eks-node-role |
aws_security_group_rule | Composite, underscore-joined | sg-0abc_ingress_tcp_443_443_0.0.0.0/0 |
aws_route53_record | Zone/name/type joined by _ | Z1234_example.com_A |
That composite-ID pattern is exactly why the old CLI was painful at scale, and why you want config generation doing the lookups where it can.
Importing into modules, for_each, and count
The to address is a full Terraform resource address, so it can point anywhere in your module tree or into a collection.
1# Into a module:
2import {
3 to = module.network.aws_vpc.main
4 id = "vpc-0abc123def456"
5}
6
7# Into a for_each resource (key in square brackets, quoted):
8import {
9 to = aws_iam_user.team["ajeet"]
10 id = "ajeet"
11}
12
13# Into a count resource (numeric index):
14import {
15 to = aws_subnet.private[0]
16 id = "subnet-0aaa111bbb222"
17}One caveat with -generate-config-out: config generation does not work for resources targeting a for_each/count instance or resources inside modules that don't already exist. For those, generate against a plain top-level address first, then refactor the HCL into your module or collection manually.
Terraform Day-2 Operations Checklist
State hygiene, drift, imports, policy checks, and upgrade routines — everything after `terraform apply` works. Plain Markdown, commit it to your repo.
Free. Instant download. You'll also get the occasional deep-dive from the newsletter — unsubscribe anytime.
Refactor safely with the moved block
Once infrastructure is under management, you'll want to reorganize it — rename resources, pull them into modules, split a monolith. Naively renaming a resource block makes Terraform see the old address as deleted and the new one as created, which for stateful infra means destroy and recreate. That's catastrophic for a database.
The moved block (Terraform 1.1+, OpenTofu 1.6+) tells Terraform that an address changed identity, so it updates state instead of destroying anything:
1# You renamed aws_instance.web -> aws_instance.api
2resource "aws_instance" "api" {
3 # ...unchanged config...
4}
5
6moved {
7 from = aws_instance.web
8 to = aws_instance.api
9}It works for moving into modules too, which is the common refactor when you extract a module:
moved {
from = aws_instance.api
to = module.compute.aws_instance.api
}Run terraform plan and you should see 1 moved, 0 to add, 0 to change, 0 to destroy. moved blocks are safe to keep in the codebase — they're idempotent — but most teams remove them a release or two after the move has been applied everywhere.
Drop from state with the removed block
The mirror image of import: sometimes you want Terraform to stop managing a resource without destroying the real thing. Maybe ownership moved to another team, another state file, or you're decomposing a large root module. The old way was the imperative terraform state rm, which — like the old import CLI — happens instantly, off the record, outside code review.
The removed block (Terraform 1.7+, OpenTofu 1.7+) makes this declarative and reviewable:
1# Delete the resource block entirely, then add:
2removed {
3 from = aws_instance.api
4
5 lifecycle {
6 destroy = false # forget it from state, do NOT destroy the real instance
7 }
8}With destroy = false, terraform apply removes the resource from state and leaves the live infrastructure untouched — the reviewable equivalent of terraform state rm. The nested lifecycle block is required (omitting it is a validation error), and its destroy argument is what decides the outcome: destroy = false only forgets the resource from state, while destroy = true actually deletes the live infrastructure. Read that block carefully — the difference is a safe refactor versus a real deletion. Like moved, you delete the removed block once the change has propagated.
The four tools at a glance
| Tool | Availability | What it's for | Touches real infra? |
|---|---|---|---|
terraform import (CLI) | Always | Imperative one-off import; maps one address to one object | No (state only) |
import block | TF 1.5+ / OpenTofu 1.6+ | Declarative, reviewable import; supports config generation | No (state only) |
moved block | TF 1.1+ / OpenTofu 1.6+ | Rename/relocate a resource without destroy-recreate | No (state only) |
removed block | TF 1.7+ / OpenTofu 1.7+ | Forget a resource from state (optionally destroy it) | No, unless destroy = true |
The through-line: the block forms move state-manipulation into your configuration, where the plan describes it and code review catches it, instead of into imperative CLI commands that mutate state the instant you hit enter.
A practical migration workflow
Adopting a real estate is a project, not a command. Here's the workflow I use.
1. Inventory first. You can't import what you can't enumerate. Pull the real list of objects from the cloud API before writing any HCL:
# Example: every EC2 instance ID and Name tag in a region
aws ec2 describe-instances \
--query 'Reservations[].Instances[].[InstanceId, Tags[?Key==`Name`]|[0].Value]' \
--output tableDo this per resource type you intend to manage. For large estates, tools like former2 or aws2tf can bulk-generate import blocks — treat their output as a draft, same as -generate-config-out.
2. Import in small batches. Do not try to import a whole account in one PR. Group by blast radius — one module or one logical stack (a VPC and its subnets; one service and its IAM). Small batches keep the plan readable and the review meaningful.
3. Verify the plan is a clean no-op. This is the whole game. After importing a batch, iterate:
terraform plan
# Goal:
# Plan: 0 to add, 0 to change, 0 to destroy.If the plan wants to change something, your HCL disagrees with reality — usually a default you over-specified, a tag you missed, or a normalized value (an IAM policy JSON whose key order differs). Fix the config, not the infrastructure. If the plan wants to destroy and recreate, stop immediately: you've got a mismatched immutable attribute (wrong ami, wrong AZ) and applying it would delete the real resource.
4. Commit once the batch is a no-op, then move to the next. A green no-op plan is your checkpoint. Merge it, then repeat with the next batch.
Watch out for drift and partial imports. Two failure modes bite hardest:
- Drift during migration. If someone changes infra in the console while you're mid-import, your carefully tuned HCL suddenly shows a diff. Freeze manual changes to anything you're actively importing, or you'll chase a moving target.
- Partial imports. A resource with sub-resources (an S3 bucket with versioning, encryption, and policy as separate resources in the AWS provider) needs each piece imported. Import just
aws_s3_bucketand the next plan will happily try to remove the versioning and encryption because your config doesn't declare them yet. Import the whole family together.
For the surrounding infrastructure decisions, Terraform for EKS infrastructure as code shows the module structure I import estates into, and if you're weighing the engine itself, OpenTofu vs Terraform covers the fork — reassuringly, every block form above works identically on both.
See also
- Migrate a Terraform Project to OpenTofu (Safely, with State and CI)
- Import Existing AWS Infrastructure into Terraform with import Blocks
Frequently Asked Questions
What's the difference between the import block and terraform import?
terraform import is an imperative CLI command that immediately writes a single state entry and generates no HCL — it's off the record and one resource at a time. The import block is declarative: you write it into your configuration, it appears in terraform plan, it goes through code review, and it can scaffold HCL for you with -generate-config-out. Nothing is committed to state until terraform apply. For any migration beyond a quick one-off, the block is the better tool.
Does -generate-config-out produce production-ready HCL?
No — treat it as a first draft. It dumps every attribute including provider defaults, leaves sensitive values blank, and writes references as hardcoded literals rather than as resource.attr references. It also can't generate for_each, depends_on, or lifecycle blocks. It gets the values right, which saves enormous time, but you must clean up the structure by hand before the config is maintainable.
How do I import a resource that lives inside a module?
Set the import block's to to the full module address, e.g. to = module.network.aws_vpc.main. That works for applying the import. Note that -generate-config-out won't scaffold config for resources inside a module or for for_each/count instances — generate against a plain top-level address first, then move the generated block into your module or collection manually.
How is the removed block different from terraform state rm?
Both stop Terraform from managing a resource without destroying it (when you set lifecycle { destroy = false }). The difference is process: terraform state rm mutates state instantly from the CLI with no review, while the removed block is declarative — it shows up in the plan and goes through your pull request. It's the same reason the import block beats the import CLI: state changes belong in code, not in one-off commands.
Will importing existing infrastructure delete or recreate it?
Not if you reach a clean no-op plan first. Import only writes a state mapping; it never creates or destroys the real object. The danger is applying a plan that shows destroy and recreate, which happens when your HCL disagrees with reality on an immutable attribute (wrong AMI, wrong availability zone). Always iterate terraform plan until it reports 0 to add, 0 to change, 0 to destroy for the imported batch before you apply.
For the wider Terraform picture, see Terraform vs Pulumi for infrastructure as code, the Terraform Kubernetes provider trap for a common migration footgun, and enforcing policy as code with OPA, Sentinel, and Checkov to keep the imported estate compliant.
If an interrupted import leaves the state locked, see fixing "Error acquiring the state lock".
Staring at a production estate that Terraform has never touched? Talk to us at Coding Protocols — we run brownfield Terraform adoptions to a clean, reviewable no-op plan without risking your running infrastructure.
Official References
- Terraform documentation — configuration language, state and provider behaviour
- Terraform state — remote backends, locking and drift
Was this article helpful?
Be the first to rate this article
Related Topics
Found this useful? Share it.


