Bring existing Azure infra under Terraform, fast

Bringing existing Azure infrastructure under Terraform management is one of those tasks that sounds simple until you actually do it. Terraform has supported import {} blocks since 1.5, but writing them by hand is tedious: every block needs the exact Azure resource ID, and a lot of those IDs are surprisingly hard to look up.
I built a small tool to do it for you — straight from a binary .plan file, with no terraform init, no provider plugins, and no terraform CLI required. It's on PyPI as generate-imports-from-plan.
The problem
Say you have a Terraform configuration that describes resources which already exist in Azure. You run a plan, and Terraform wants to create everything — because it doesn't know those resources are already there. The fix is to write an import {} block per resource:
import {
to = module.core.azurerm_resource_group.this
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-myapp-dev-we-01"
}
For a handful of resources, fine. For a real environment with dozens of resource groups, networks, role assignments and Entra ID apps, doing this by hand is a long afternoon of copy-pasting IDs out of the portal.
The obvious idea is to script it from terraform show -json. But there's a catch.
Why terraform show -json isn't enough
terraform show -json <planfile> does not expose all planned attribute values. Computed fields — including things you need to build the resource ID, like the resource name or the resource group name — show up as "known after apply" and are simply absent from the JSON.
The values are in the plan, though. A .plan file is actually a ZIP containing:
tfplan— the planned changes, as protobuftfstate— the prior statetfconfig/— the raw HCL of your configuration
Inside tfplan, attribute values are msgpack-encoded, and they include the computed values that the JSON view drops. So instead of going through terraform show, this tool reads the binary plan directly: unzip, parse the protobuf, decode the msgpack attributes. No provider plugins needed, because nothing is being evaluated — the values are already sitting in the plan.
How IDs get resolved
Azure resource IDs come in a few flavours, and the tool handles each differently:
Formula-based IDs. Most ARM resources have a deterministic ID you can assemble from their attributes — subscription, resource group, provider namespace, name. These are built from a formula and emitted immediately, no prompting.
Cross-plan IDs. Some resources have an ID that depends on another resource in the same plan — a subnet association needs the subnet ID, a DNS-servers resource needs the VNet ID. The tool reads the HCL references in
tfconfig/and derives those IDs from the sibling resource, again without any external calls.Live IDs. A few resource types only get their ID assigned at creation time — Entra ID resources, Azure role assignments. For those, the tool calls the Azure CLI (
az) to look up the real ID, and it shows you the exact command before running it.
There's one more nice detail: the correct subscription ID is determined per resource by following the azurerm provider chain through the config. A plan that spans multiple subscriptions gets the right subscription on each resource — no single global assumption.
Using it
The tool is published to PyPI, so the quickest path (nothing installed permanently) is uvx:
uvx generate-imports-from-plan terraform.plan > imports.tf
If you don't have uv, install it (pip install uv, or winget install astral-sh.uv on Windows), or install the tool directly with pip/pipx:
pipx install generate-imports-from-plan
generate-imports-from-plan terraform.plan > imports.tf
You'll need Python 3.11+ and — only for the live lookups — the Azure CLI.
The workflow
# 1. Generate the plan
terraform plan -out terraform.plan
# 2. Generate import blocks interactively
generate-imports-from-plan terraform.plan > imports.tf
# 3. Fill in any remaining placeholder IDs, then re-plan and apply
terraform plan -out terraform.plan
terraform apply terraform.plan
The tool is interactive and decides what to do per resource:
| Situation | Behaviour |
|---|---|
| Complete formula-based ID | Emitted immediately, no prompt |
| Cross-plan ID (depends on another resource in the plan) | Resolved and emitted automatically |
| Entra ID resource or role assignment | Shows the az command and asks for confirmation |
| Unresolvable ID | Shows which attribute is computed and its HCL reference chain; asks whether to skip |
Unsupported import (e.g. azuread_application_password) |
Emits a comment block instead |
Use --yes to accept everything without prompting, or --no for a dry run.
Sample output:
import {
to = module.core.azurerm_resource_group.this
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-myapp-dev-we-01"
}
import {
to = module.core.azuread_application.default["my-app"]
id = "/applications/00000000-0000-0000-0000-000000000000"
}
# import not supported for azuread_application_password:
# module.core.azuread_application_password.default["my-app"]
Extending it
Adding support for a new resource type is usually a one-line formula. For example, in generate_imports/ids.py:
"azurerm_my_resource":
lambda a, s: _arm(s, _str(a, "resource_group_name"),
"Microsoft.MyNamespace/myResources", _str(a, "name")),
For the trickier cases there are also cross-plan resolvers (derive an ID from a sibling resource) and live resolvers (az CLI). The repo's docs/resolvers.md walks through all three.
Links
- 📦 PyPI: generate-imports-from-plan
- 💻 Source & docs: github.com/cveld/terraform-importer
If you try it on your own environment, I'd love to hear which resource types you hit that aren't covered yet — issues and PRs are welcome.