OnCallReady

Lesson 13.15 · Terraform: State & Modules · 21 min read

The state commands: list, show, mv, rm, pull, push, replace-provider

In plain words

Back to the coat check. Sometimes you need to fix the tickets without touching the coats. A coat moved to a different hook, so you rewrite the ticket to point at the new hook. A coat is being given away, so you tear up its ticket but leave the coat where it is. You photocopy the whole ticket book before doing anything risky.

That is what the terraform state commands do: they change only the tickets, never the coats. state mv re-addresses an object (azurerm_subnet.s[0] to azurerm_subnet.s["web"]), state rm forgets one while it keeps running in Azure, state pull prints the raw JSON for a backup, state push overwrites the remote state and is the dangerous one, and state list / state show just read.

The problem

Someone renamed a block from logs to diagnostics, and the plan now wants to destroy a storage account full of logs and create an empty one. Another team wants to take over a Key Vault (Azure's service for storing secrets and keys) that your state manages. A script wiped the state and you have a backup.

None of these needs a change in Azure. They need a change in state: which address points at which object, or whether Terraform knows about an object at all. The terraform state commands do exactly that.

What you need to know already:

The state commands

terraform state list [ADDR...]        every address in state (or those under a prefix)
terraform state show ADDR             one instance's attributes
terraform state mv SRC DST            re-address an object - state only
terraform state rm ADDR...            forget objects - state only, Azure untouched
terraform state pull                  print the raw state JSON (any backend)
terraform state push FILE             overwrite remote state with a file (dangerous)
terraform state replace-provider A B  change the provider address recorded for resources
terraform show [-json]                the whole state, human- or machine-readable

Every one of them only changes state (or only reads it). None of them calls Azure.

That makes them safe and dangerous at once. They can fix a mistake without touching any real infrastructure - and they can create a mistake that the next apply turns into an outage.

One word you will see in the examples: module.network. in front of an address. A module is a folder of Terraform code that your code calls; the resources inside it get addresses that start with module.<name>. (13.34 explains modules properly).

Reading: list and show

terraform state list prints every address in the state:

# in a configuration with a module.network and a for_each subnet
terraform state list
azurerm_resource_group.main
azurerm_subnet.this["app"]
azurerm_subnet.this["data"]
module.network.azurerm_virtual_network.this

terraform state list module.network
module.network.azurerm_virtual_network.this

terraform state list -id=/subscriptions/.../resourceGroups/rg-orders-dev
azurerm_resource_group.main

terraform state show ADDR prints one instance's attributes, written like a resource block:

# same configuration
terraform state show 'azurerm_subnet.this["app"]'
# azurerm_subnet.this["app"]:
resource "azurerm_subnet" "this" {
    address_prefixes     = [
          "10.20.1.0/24",
        ]
    id                   = "/subscriptions/.../subnets/app"
    name                 = "app"
    resource_group_name  = "rg-orders-dev"
    virtual_network_name = "vnet-orders-dev"
}

state show needs one instance: azurerm_subnet.this alone is refused when it has keys.

Always put addresses with brackets in single quotes: 'azurerm_subnet.this["app"]'. Without them, bash eats the double quotes and may treat [ ] as a glob.

Re-addressing: state mv

terraform state mv SRC DST changes the address an object is known by. Four typical moves - a rename, count index to for_each key, into a module, and a module rename:

terraform state mv azurerm_storage_account.logs azurerm_storage_account.diagnostics
terraform state mv 'azurerm_subnet.s[0]' 'azurerm_subnet.s["web"]'
terraform state mv azurerm_virtual_network.main module.network.azurerm_virtual_network.this
terraform state mv module.net module.network
Move "azurerm_storage_account.logs" to "azurerm_storage_account.diagnostics"
Successfully moved 1 object(s).

The real object keeps its Azure ID; only its address in state changes. You then change the configuration to match - in the same pull request (PR) - and the next plan is clean.

Rules:

The reviewable alternative is a moved block in the code (13.46). Use state mv for a one-off fix on one state. Use moved when the change should be reviewed in a PR, or when a shared module must move every caller's state.

Forgetting: state rm

terraform state rm ADDR makes Terraform forget an object:

# a state with a legacy storage account the config no longer declares
terraform state rm 'azurerm_storage_account.legacy'
Removed azurerm_storage_account.legacy
Successfully removed 1 resource instance(s).

The storage account keeps running (and costing money). Terraform just no longer knows about it.

Two legitimate reasons:

And one trap: if the block is still in the configuration, the next plan wants to create it again, and the apply fails because the name is taken. So state rm always goes together with deleting (or moving) the block.

Since Terraform 1.7 the reviewable form is a removed block with lifecycle { destroy = false } (13.46).

state rm -dry-run shows what would be removed. With a prefix it removes everything under it: terraform state rm module.legacy forgets a whole module.

The raw state: pull and push

state pull prints the JSON from whatever backend you use. It is your backup before any surgery, and your input for jq:

terraform state pull > backup-$(date +%Y%m%d-%H%M).tfstate
terraform state pull | jq '.resources | length'

The first line saves a backup with the date and time in its name ($(date +%Y%m%d-%H%M) expands to something like 20260923-0612). The second counts the resource blocks in state.

state push FILE does the opposite: it overwrites the state in the backend with a local file. It is the last resort for recovering a broken or emptied state from a backup, and it has two safety checks (13.1):

Failed to write state: cannot import state with serial 5 over newer state with serial 8
Failed to write state: cannot import state with lineage "9a1f..." over unrelated state with lineage "3f2b..."

state push -force skips both checks. Before using it:

For "undo the last write", restoring an older blob version (versioning, 13.4) is usually cleaner.

replace-provider

State records, for every resource, the source address of the provider that manages it, like registry.terraform.io/hashicorp/azurerm (12.3). If that address changes - the provider moved to a new owner, or you download it from your company's own copy - state still has the old one, and init and plan complain that the state refers to a provider the configuration does not use.

terraform state replace-provider OLD NEW rewrites it:

# a state written by Terraform 0.12 with the legacy provider namespace
terraform state replace-provider registry.terraform.io/-/azurerm registry.terraform.io/hashicorp/azurerm
Terraform will perform the following actions:

  ~ Updating provider:
    - registry.terraform.io/-/azurerm
    + registry.terraform.io/hashicorp/azurerm

Changing 3 resources:

  azurerm_resource_group.main
  azurerm_virtual_network.main
  azurerm_subnet.app

Do you want to make these changes?
Only 'yes' will be accepted to continue.

The output lists the old (-) and new (+) address and every resource that changes. The famous case was the upgrade from Terraform 0.12 to 0.13, when addresses without an owner (-/azurerm) became hashicorp/azurerm. You meet it on old states, and on the exam.

terraform show

terraform show                 # the whole state, human-readable
terraform show -json | jq ...  # the same as JSON
terraform show tfplan          # a SAVED PLAN, human-readable
terraform show -json tfplan    # a saved plan as JSON - what automated checks read

show on state is a read-only snapshot; it does not re-read Azure.

Working safely

before       terraform state pull > backup.tfstate
dry run      terraform state mv -dry-run ...  /  state rm -dry-run ...
do it        one command at a time, reading the output
verify       terraform plan  -> must be what you expected (usually: No changes)
commit       the matching configuration change, in the same PR

And three questions before any state command on shared state:

What you can now do:

Why it helps

These are your repair tools. Situations: a ticket gives you a raw Azure resource ID and asks who manages it; terraform state list -id=... answers in one line. A resource was renamed in code and the plan wants to destroy and recreate a subnet; a state mv (or better a moved block) fixes it. A state got emptied by a bad migration; state push from a backup, understanding the serial and lineage checks, gets it back. A resource must be handed to another team; state rm in yours, import in theirs. The key insight, and the exam's favourite: none of these commands call Azure, so they can fix a mistake silently, or create one the next apply turns into an outage.

FAQ

Does terraform state rm delete the resource in Azure?

No. It only removes the entry from state; the object keeps running and billing. The trap: if the resource block is still in the configuration, the next plan wants to create it again, and the apply fails on the name. Always pair state rm with deleting or moving the block, or use a removed block with destroy = false (1.7+), which does both reviewably.

When should I use state mv instead of a moved block?

Use a moved block by default: it is in code, reviewed in a PR, visible in the plan, and applies to every state that uses the module. Use state mv for a one-off repair on a single state, for example fixing an address after an accident. Either way, run with -dry-run or a plan first, back up with state pull, and commit the matching configuration change.

Why does state show refuse my address?

state show needs exactly one instance. For a resource with count or for_each, azurerm_subnet.this alone is ambiguous; use azurerm_subnet.this["app"] or [0]. Quote the whole address in single quotes, because the shell will otherwise strip the double quotes or try to glob the brackets. state list shows the exact addresses to copy.

When would I ever use state push?

Rarely: to restore a corrupted or accidentally emptied state from a backup. It refuses an older serial over a newer one and a different lineage; -force skips both checks. Before using it, stop every pipeline on that state, pull a fresh backup of what you are overwriting, and understand why the serial is behind. For undoing the last write, blob versioning on the storage account is usually cleaner.

What is state replace-provider for?

When a provider's source address changes (a fork, an internal mirror, a namespace move), state still records the old address and init or plan complain. terraform state replace-provider OLD NEW rewrites the recorded address for the affected resources. The historic case was Terraform 0.13, when unnamespaced -/azurerm became hashicorp/azurerm; you still meet it on old states and on the exam.

In an interview Mid

You renamed a resource in code and the plan wants to destroy and recreate it. How do you fix it?

Terraform matches config to state by address. A rename looks like one object deleted and another added, so it plans a destroy and a create of the same thing.

Tell it about the rename:

moved {
  from = azurerm_storage_account.logs
  to   = azurerm_storage_account.diagnostics
}

Either way the real object keeps its ID; only its address changes. The check is the next plan: has moved to, and 0 to add, 0 to destroy.

Also asked: What do terraform state list and terraform state show tell you? · What does terraform state rm do, and what does it leave behind? · When would you use terraform state push?

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