The id in a Terraform state file is often not the one you import by
As often happens when Terraform organically grows, I found myself needing to build a module to make the code DRY.
Now when consuming those modules, if you are simply replacing the none-DRY code with the module, Terraform moved blocks will do the job.
It's not always that simple though, and if you are instead moving resources between state files but are not able to destroy the resources, you need to use a combination of removed and import blocks on the 2 states.
The obvious way to generate them is to walk the state file and copy each resource's id into a block, and for a lot of AWS types that gives you an id Terraform can't import.
Where the state id and the import id part ways
Each AWS resource type has its own import id format, documented on its page in the provider docs. The state id is whatever the provider's SetId call happened to store, and the two are the same string less often than you'd hope. A few examples, with the values from the tool's test fixture:
| Type | id in the state | What terraform import wants |
|---|---|---|
aws_route | r-rtb-656C65616E6F721080289494 (a hash) | rtb-656C65616E6F72_0.0.0.0/0 |
aws_security_group_rule | sgrule-1859128000 (a hash) | sg-6e616f6d69_ingress_tcp_8000_8000_10.0.3.0/24 |
aws_wafv2_web_acl | a bare UUID | <id>/<name>/<scope> |
aws_ecs_service | the service ARN | prod-cluster/web |
The first three are at least obviously wrong: a hash or a UUID can't be mistaken for an import id. The import ids are built from other attributes, six of them in the security group rule's case, with the ports read as numbers and the CIDRs as a list.
aws_ecs_service is the sneaky one. The import wants <cluster name>/<service name>, and neither name is in the state as a plain value: the id is the service's ARN and the cluster attribute is the cluster's ARN, not its name. The rule takes the name off the end of the cluster ARN and pairs it with the service's name. If cluster isn't set, which is allowed because a service defaults to the account's default cluster, it reads the cluster out of the service ARN instead. ARNs from before 2019 don't name a cluster at all, and in that case the rule returns nothing and flags the block rather than guess default.
Then there are the separators, which differ by type for no reason you could work out from the state:
| Type | Import id format |
|---|---|
aws_eks_node_group | cluster:node_group |
aws_ecs_service | cluster/service |
aws_backup_selection | plan_id and selection_id, joined with a pipe character |
aws_ram_resource_association | share_arn,resource_arn |
aws_ec2_transit_gateway_route | route_table_id_destination |
aws_iam_role_policy_attachment | role/policy_arn |
aws_iam_role_policy | role:name |
None of that is guessable, and a tool that guesses writes a block that looks fine and fails hours later inside somebody else's terraform plan. Or succeeds against the wrong resource.
What the tool does
tf-import-blocks reads a state file (a raw .tfstate, terraform state pull output or terraform show -json output, detected rather than declared) and emits one import block per managed aws_ resource. The id comes from a per-type rule table in src/rules/, built from the Import section of each type's provider doc page, roughly 250 of them. It covers 234 types: 229 resolve and 5 are marked not importable because the provider publishes no import for them, aws_vpn_gateway_attachment for one.
terraform state pull > prod.tfstate
npx tf-import-blocks prod.tfstate > imports.tfOnly HCL goes to stdout, so the redirect is always safe. The summary goes to stderr and reports counts, types and addresses, never an id, because stderr is the thing that ends up pasted into a ticket. Run against the repo's test fixture, trimmed:
# account 111122223333 · region eu-west-1
import {
to = aws_ecs_service.web
id = "prod-cluster/web"
}
import {
to = aws_route.default
id = "rtb-656C65616E6F72_0.0.0.0/0"
}
# VERIFY: no rule for aws_s3_object — import id may not be the state id
import {
to = aws_s3_object.weird
id = "acme-assets-eu-west-1/reports/\"q1\"/$${env}/summary.txt"
}tf-import-blocks: 15 blocks from 1 state file
14 resolved by a rule
1 fell back to the state id, flagged # VERIFY
no rule: aws_s3_object
skipped: 1 deposed instance, 1 data source, 1 non-aws resourceTwo things in there I decided on purpose. A type with no rule still gets a block, built from the state id and flagged # VERIFY, because silently dropping a resource during a state move is the worst outcome: nothing manages it any more and you find out when something destroys it. And the composite rules have no fallback. If aws_route can't find a destination attribute it returns nothing and the block says so, rather than falling back to the hash. A flagged wrong id is a quick fix, and an unflagged one isn't.
The # account · region comment is there instead of a provider = argument. I can't know your provider alias names, Terraform rejects provider on an address inside a module anyway, and importing into the wrong account is silent and expensive.
--filter module.net. emits only one subtree, which is the common case for a move. Exit code 3 means two state files gave the same address and the output isn't valid HCL until you pick one.
It's on npm as tf-import-blocks, Node 20 or newer, no runtime dependencies. The source is on GitHub.
What it doesn't do
It writes import blocks and nothing else. Terraform can write the resource bodies for you, with terraform plan -generate-config-out=generated.tf, but only once the import blocks exist, because that is what it reads to know what to import. Nothing in Terraform writes the blocks themselves from a state file, and that is the part this covers.
AWS only. Data sources, deposed instances and resources from other providers are skipped and counted.
The blocks use the string id = "…" form only, not the newer identity = { … } form, which needs Terraform 1.12 and a provider that publishes an identity schema for that type. The provider docs now print the identity example first on every page, so if you're adding a rule, scroll past the first import { block. The source comments repeat that warning a lot.
aws_s3_object deliberately has no rule. It's the fixture's no-rule case and exercises the ${ escaping, so adding one would break the golden test for a reason that lives in a fixture. aws_s3_bucket_object, its deprecated predecessor, follows it. Both come out with a # VERIFY.
The fixture is synthetic. It has been run against a real state, which is messier than anything the tests see.

whoami
Ben Ralton — Cloud engineering, Full Stack Development & DevOps
more posts
My shared Postgres cluster went 133 days without a single metric being scraped
Prometheus never scraped the shared CNPG Postgres cluster in my homelab, from the day it was deployed until I went lo…
Claude.ai said no MCP server was found at my URL. It was a 404 on a method I hadn't implemented.
After OAuth succeeded, claude.ai reported no MCP server at my URL while Claude Code connected fine. The server only h…
What runs in my homelab, and why it's Kubernetes
A four-node K3s cluster built from a 2014 Mac Mini, two Raspberry Pis and a NAS. It didn't need to be Kubernetes. I p…