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:
- what is inside state: addresses, instances,
index_key(13.1) - serial and lineage (13.1)
count/for_eachaddresses likeazurerm_subnet.this["app"](12.20)- shell quoting: single quotes keep
"and[ ]away from bash (6.6)
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
- With an address prefix (
module.network) it lists only what is under it. -id=<resource ID>answers "which address manages this Azure object?" - the question you ask when someone pastes a resource ID into a ticket.
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:
- Types must match: an
azurerm_subnetcan only move to anotherazurerm_subnetaddress. - The destination must be free: moving onto an address that already has an object is refused.
- Moving a whole resource moves all its instances and keeps their keys; moving one instance moves just that one.
- Moving a module moves everything inside it.
-dry-runprints what would happen and changes nothing. Use it first.
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:
- Handing an object to another state - then
importit there (13.25). - Giving an object up - it should keep existing but not be managed.
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:
- stop every pipeline for that state;
- take a fresh
pullbackup of what you are about to overwrite; - be sure you understand why the serial is behind.
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:
- Is anything else running against this state right now? (The command takes the lock, but a pipeline queued behind you will then plan against your change.)
- Will the configuration match the new state when I am done?
- Could a
moved,removedorimportblock do this in a reviewable way instead?
What you can now do:
- find an address with
state list(also by Azure ID) and inspect it withstate show - rename with
state mvand forget withstate rm, knowing what each leaves behind - back up with
state pulland know whenstate pushis justified