Why import exists
Your team adopts Terraform. The production storage account was made by hand two years ago and holds millions of files. You write a block for it and run apply - and azurerm refuses to create something that already exists. You cannot delete it either. How does Terraform take it over?
With import: it writes a state entry that says "this address manages that existing object". Nothing in Azure changes.
What you need to know already:
- state maps addresses to Azure resource IDs (13.1)
for_eachandtoset()(12.20, 12.14)- orphans left by a dead apply (13.11)
- reading a plan:
~update in place,-/+replace (12.24)
Where unmanaged objects come from
Things get built outside Terraform all the time:
- by hand, before the team used Terraform;
- by a colleague in the portal during an incident;
- by an apply that created an object and died before writing state (an orphan, 13.11).
They exist in Azure and not in state. Two things you cannot do with them:
- create them again - azurerm refuses:
│ Error: A resource with the ID "/subscriptions/.../storageAccounts/stordersexportsdev"
│ already exists - to be managed via Terraform this resource needs to be imported
│ into the State. Please see the resource documentation for
│ "azurerm_storage_account" for more information.
- delete and recreate them - they hold data, or other things point at them.
So you import (adopt) them.
Resource IDs
Import needs the object's ID. For Azure that is the full resource ID (13.1), and the pattern is regular enough to write by hand:
resource group /subscriptions/{sub}/resourceGroups/{rg}
virtual network /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Network/virtualNetworks/{vnet}
subnet .../providers/Microsoft.Network/virtualNetworks/{vnet}/subnets/{subnet}
NSG .../providers/Microsoft.Network/networkSecurityGroups/{nsg}
public IP .../providers/Microsoft.Network/publicIPAddresses/{pip}
storage account .../providers/Microsoft.Storage/storageAccounts/{account}
key vault .../providers/Microsoft.KeyVault/vaults/{vault}
AKS cluster .../providers/Microsoft.ContainerService/managedClusters/{cluster}
Linux VM .../providers/Microsoft.Compute/virtualMachines/{vm}
Log workspace .../providers/Microsoft.OperationalInsights/workspaces/{workspace}
managed identity .../providers/Microsoft.ManagedIdentity/userAssignedIdentities/{name}
How to read it: {sub} is the subscription ID, {rg} the resource group, then providers/<Azure service>/<kind>/<name>. Child objects (a subnet in a VNet) add /<kind>/<name> at the end.
Kinds in that list you have not met yet, one line each:
- public IP: an internet-reachable address you rent from Azure;
- AKS cluster: Azure's managed service for a group of machines that run containers for you;
- Log workspace (
azurerm_log_analytics_workspace): Azure's store for logs you can search; - managed identity: an identity Azure creates for a program, so it can sign in to other services without a password.
On a real machine the Azure CLI prints an ID:
az resource show -g <rg> -n <name> --resource-type <type> --query id -o tsv
-g resource group, -n name, --resource-type e.g. Microsoft.Storage/storageAccounts, --query id pick just the id field, -o tsv print it as plain text.
Every azurerm resource page on the Terraform Registry (registry.terraform.io, 12.3) ends with an Import section showing the exact format. A few resources use other IDs: a Key Vault secret is its URL (https://<vault>.vault.azure.net/secrets/<name>/<version>), and the link between an NSG and a subnet uses the subnet's ID. Always check the docs page.
Case matters in some IDs (resourceGroups, providers/Microsoft.Network), and a wrong ID fails clearly:
│ Error: Cannot import non-existent remote object
│
│ While attempting to import an existing object to "azurerm_resource_group.ghost",
│ the provider detected that no object exists with the given id. Only
│ pre-existing objects can be imported; check that the id is correct and that it
│ is associated with the provider's configured region or endpoint, or use
│ "terraform apply" to create a new remote object for this resource.
Two ways in
1. The import command
terraform import ADDRESS ID adopts one object:
terraform import azurerm_resource_group.legacy /subscriptions/.../resourceGroups/rg-legacy
azurerm_resource_group.legacy: Importing from ID "/subscriptions/.../resourceGroups/rg-legacy"...
azurerm_resource_group.legacy: Import prepared!
Prepared azurerm_resource_group for import
azurerm_resource_group.legacy: Refreshing state... [id=/subscriptions/.../resourceGroups/rg-legacy]
Import successful!
The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.
What to know about it:
- The resource block must already exist in the configuration; the command does not write it for you:
│ Error: resource address "azurerm_resource_group.legacy" does not exist in the configuration.
│
│ Before importing this resource, please create its configuration in the root module.
(The root module is simply the directory you run Terraform in; 13.34.)
- It imports one object per run, writes state immediately, and shows no plan. Whoever runs it changes shared state from their own shell.
- Importing to an address that already has an object is refused (Resource already managed by Terraform).
- For
for_each/countinstances, quote the address:terraform import 'azurerm_subnet.this["app"]' /subscriptions/.../subnets/app.
2. Import blocks (Terraform 1.5+)
An import block writes the same thing into your code instead:
import {
to = azurerm_storage_account.reports
id = "/subscriptions/.../resourceGroups/rg-reports/providers/Microsoft.Storage/storageAccounts/streports01"
}
to is the address that should manage it, id the Azure ID. The import then happens as part of plan and apply:
# azurerm_storage_account.reports will be imported
resource "azurerm_storage_account" "reports" {
account_replication_type = "GRS"
account_tier = "Standard"
id = "/subscriptions/.../storageAccounts/streports01"
name = "streports01"
...
}
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
Apply complete! Resources: 1 imported, 0 added, 0 changed, 0 destroyed.
Note the new count in the summary: 1 to import.
Why it is the better default in a team:
- it goes through review like any other change: PR, saved plan, approval;
- many objects in one plan, and changes to them in the same plan;
- it is idempotent (12.1): once imported, the block does nothing, and you can delete it in a later commit;
tomay point into a module:to = module.network.azurerm_subnet.this["app"](the import block itself must be in the root module).
Many objects: for_each in an import block (1.7+)
locals {
legacy_groups = toset(["rg-reports-weu", "rg-reports-neu", "rg-reports-archive"])
}
import {
for_each = local.legacy_groups
to = azurerm_resource_group.legacy[each.key]
id = "/subscriptions/${local.sub}/resourceGroups/${each.key}"
}
resource "azurerm_resource_group" "legacy" {
for_each = local.legacy_groups
name = each.key
location = "westeurope"
}
One block, one import per element, addressed by key. Pair it with a for_each resource over the same collection, so every key has an address to land on.
Letting Terraform write the configuration
If an import block's to has no resource block yet, plan can write one for you:
terraform plan -generate-config-out=generated.tf
-generate-config-out=FILE writes the missing resource blocks into FILE.
# azurerm_storage_account.reports will be imported
# (config will be generated)
...
Terraform has generated configuration and written it to generated.tf. Please review
the configuration and edit it as necessary before adding it to version control.
# __generated__ by Terraform
# Please review these resources and move them into your main configuration files.
# __generated__ by Terraform from "/subscriptions/.../storageAccounts/streports01"
resource "azurerm_storage_account" "reports" {
account_replication_type = "GRS"
account_tier = "Standard"
location = "westeurope"
name = "streports01"
resource_group_name = "rg-reports"
tags = {
cost_center = "CC-4411"
}
}
It saves typing, not thinking. The real provider writes every argument it knows - dozens for a storage account, many just at their defaults. Clean it up:
- delete arguments that only repeat the default;
- replace fixed values with references (
resource_group_name = azurerm_resource_group.reports.name) and variables; - move it into the right file.
The target file must not exist yet - the command refuses to overwrite it.
After the import: the plan must be clean
The import is not finished when state has the object. It is finished when a plan shows no changes for it. Any difference between your block and the real object is a change the next apply will make:
# azurerm_resource_group.legacy will be updated in-place
~ resource "azurerm_resource_group" "legacy" {
~ tags = {
- "cost_center" = "CC-4411" -> null
- "created_by" = "portal" -> null
}
}
Those tags were added by hand years ago. Your block does not have them, so the apply would remove them (-> null). Put them in the block (or use ignore_changes, 13.21), then plan again until it is clean.
A worse version of the same mistake: an import whose block has a different name. The plan then wants to replace the object you just adopted.
Importing many things: aztfexport
For whole resource groups built by hand, Microsoft's Azure Export for Terraform (aztfexport) generates configuration and import blocks for everything it finds. It is a starting point for the same cleanup as above, not a finished configuration. (Not installed in the lab.)
import vs data vs removed
import take OWNERSHIP of an existing object resource block + state entry
data source READ an object someone else owns no state ownership
removed GIVE UP ownership without destroying state entry removed
Moving an object from one state to another is removed in one configuration plus import in the other - 13.30.
What you can now do:
- build an Azure resource ID and adopt an object with
terraform importor an import block - generate a starting configuration with
-generate-config-out - finish an import properly: a plan with no changes