OnCallReady

Lesson 13.25 · Terraform: State & Modules · 26 min read

Import: adopting what already exists

In plain words

Imagine a family moves into a house that already has a garden full of plants. They want to start looking after the garden with their own planting plan. They don't dig everything up and replant; they walk around, write each existing plant into their plan with the right name and spot, and from then on care for it like the rest.

That is import: it writes a state entry that maps an address in your code (azurerm_storage_account.reports) to an existing Azure object ID, without changing anything in Azure. You can do it with the terraform import command, or better with an import { to = ... id = ... } block (1.5+) that goes through plan and review. -generate-config-out drafts the resource block for you. The job is finished only when plan shows no changes.

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:

Where unmanaged objects come from

Things get built outside Terraform all the time:

They exist in Azure and not in state. Two things you cannot do with them:

│ 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.

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:

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:

│ 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.)

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:

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:

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:

Why it helps

Every team adopting Terraform has years of portal-built resources, and you will lead some of that adoption: import blocks in a PR, plan until clean, then delete the blocks. Import also fixes the classic post-incident error: a cancelled apply created a storage account without recording it, and every run now fails with "already exists - needs to be imported". The trap you will catch in review: an import whose plan shows ~ tags being removed, or worse a name mismatch that plans to replace the object you just adopted. Exam questions: import command vs block, what -generate-config-out does, and import vs data source.

FAQ

Does terraform import change anything in Azure?

No. It only writes a state entry mapping your address to the existing object's ID. But the next apply will make the real object match your resource block, so if the block differs (missing tags, a different SKU), the apply changes the resource, and a different name can even force a replacement. That is why an import is only finished when the plan shows no changes for it.

Import command or import block: which should I use?

Import blocks (1.5+) in almost every team setting. They go through the normal PR, plan artifact and approval flow, import many objects in one plan (with for_each since 1.7), can target addresses inside modules, and are idempotent. The terraform import command imports one object per run, writes shared state immediately from someone's shell, and shows no plan first.

Where do I find the ID to import?

For most azurerm resources, it is the full Azure resource ID, like /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Storage/storageAccounts/{name}. az resource show ... --query id -o tsv prints it. Some resources use other IDs: a Key Vault secret uses its vault URL, associations use a composite ID. Each registry page has an Import section with the exact format; check it, since case matters.

Can Terraform write the resource block for me?

Yes: with an import block whose to address has no resource block, terraform plan -generate-config-out=generated.tf writes one. It saves typing, not thinking: it includes every argument the provider knows, many at defaults, with literals instead of references. Clean it up, move it into the right file, and plan until clean. The target file must not exist already. For whole resource groups, Microsoft's aztfexport generates configuration and import blocks.

Can I put import blocks inside a module?

No, import blocks are only allowed in the root module. But their to address can point into a module, for example to = module.network.azurerm_subnet.this["app"]. So you adopt objects into module-managed addresses from the root configuration that calls the module.

In an interview Junior

How do you bring an existing, hand-built resource under Terraform management?

Import it: write a state entry that says "this address manages that existing object". Nothing in the cloud changes.

  1. Write the resource block (or let terraform plan -generate-config-out=generated.tf write a starting one).
  2. Find the object's resource ID (the provider docs' Import section shows the format).
  3. Add an import block - reviewed in a PR, part of plan and apply:
import {
  to = azurerm_storage_account.reports
  id = "/subscriptions/.../storageAccounts/streports"
}

(The older terraform import ADDRESS ID writes state immediately with no plan.)

  1. Plan until it says 1 to import, 0 to change. Any ~ or -/+ means your block does not match reality, and the apply would change - or replace - what you just adopted.

Import takes ownership; a data source only reads something someone else owns.

Also asked: What is the difference between import and a data source? · What does -generate-config-out do, and what do you still have to do afterwards? · What is an orphaned resource, and how does one appear?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.