The problem
You applied a configuration in chapter 12 and Terraform created a resource group. You run terraform plan again and it says No changes. How does it know that the resource group in Azure is the one your block made, and not one a colleague created by hand with the same name?
It knows because it wrote that down. The notebook it writes in is called state, and this chapter is about it: what is in it, where it must live, how to change it safely, and (later) how to split code into reusable modules without Terraform losing track of what it owns.
What you need to know already:
- resources, addresses like
azurerm_resource_group.main,init/plan/apply(12.1, 12.2, 12.24) countandfor_each(12.20)- jq filters:
.field,.[],select(),-r(7.11, 7.13) - state is JSON - git, commits and
.gitignore(1.17, 1.19)
Why there is a state file at all
Your configuration says what should exist. Azure says what does exist. Neither one says which real object belongs to which block.
That mapping is state: a JSON file Terraform writes after every apply. One line of the mapping looks like this:
azurerm_resource_group.main -> /subscriptions/.../resourceGroups/rg-platform-dev
On the left is the resource address from your code. On the right is the resource ID: the full path Azure uses to name one object. It starts with the subscription (the Azure account the bill goes to, written as a long ID), then the resource group, then the object.
Terraform keeps state for four reasons. The exam asks for them by name:
mapping which real object each resource address manages
metadata dependencies, so a DELETED block is still destroyed in the right order
performance cached attributes, so a plan over 800 resources is not 800 cold reads
(plan still re-reads by default; -refresh=false skips it)
syncing one shared record when a team works on the same infrastructure
Two of these deserve a sentence each:
- Without the mapping, Terraform could not tell "I made this and it changed" from "someone else made something with the same name".
- Without the recorded dependencies, deleting a VNet block and its subnet blocks in the same commit could try to delete the VNet first - and Azure refuses to delete a VNet that still has subnets.
What is inside
terraform state pull prints the current state as JSON. It works wherever the state is stored (on your disk or in the cloud). With local state you can also just open the terraform.tfstate file.
Pipe it into jq to look at parts of it. del(.resources) means "show everything except the long resources list":
# in the anatomy mission's configuration (next step), after its apply
terraform state pull | jq 'del(.resources)'
{
"version": 4,
"terraform_version": "1.9.8",
"serial": 7,
"lineage": "3f2b1c9e-8d7a-4e6f-b5c4-a39281706f5e",
"outputs": {
"db_password": {
"value": "Zq8#tR2vLp!mW4xN7cKe9sHd",
"type": "string",
"sensitive": true
}
},
"check_results": null
}
What each top-level field means:
version the state FORMAT version (4 since Terraform 0.12)
terraform_version the Terraform that last wrote it; an older binary refuses to read
state written by a newer one
serial a counter, +1 on every write - how Terraform notices that a saved
plan or an upload is out of date
lineage a random ID (a UUID) fixed when the state was first created - two
states with different lineages are unrelated, and Terraform will
not mix them
outputs the outputs of your top-level code, values included (sensitive too)
Now the resources. .resources[0] is the first entry of the list:
# same configuration: its first resource
terraform state pull | jq '.resources[0]'
{
"mode": "managed",
"type": "azurerm_virtual_network",
"name": "main",
"provider": "provider[\"registry.terraform.io/hashicorp/azurerm\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"address_space": ["10.30.0.0/16"],
"id": "/subscriptions/.../virtualNetworks/vnet-orders-dev",
"location": "westeurope",
"name": "vnet-orders-dev",
"resource_group_name": "rg-orders-dev"
},
"sensitive_attributes": [],
"dependencies": [
"azurerm_resource_group.main"
]
}
]
}
Reading it field by field:
modeismanagedfor aresourceblock anddatafor adatablock.type+nameare the two labels of the block: together they are the addressazurerm_virtual_network.main.provideris the plugin that manages it (12.3).instances: one entry per real object. A plain block has one. A block withcountorfor_eachhas one per index or key, and each carries anindex_keyfield (0,"app"...).attributesholds every attribute the provider returned, not only the ones you wrote.idis the Azure resource ID from above.dependenciesrecords what this object depended on, so a destroy still knows the right order after the block itself is gone.
Useful one-liners (each is plain jq from chapter 7):
terraform state pull | jq '.serial'
terraform state pull | jq -r '.resources[] | "\(.type).\(.name) \(.instances | length)"'
terraform state pull | jq -r '.resources[].instances[].attributes.id'
terraform state pull | jq '.resources[] | select(.type=="azurerm_subnet") | .instances[].index_key'
In order: the write counter; every address with how many instances it has; every Azure ID Terraform manages; the for_each keys of the subnets.
Secrets live in state, in plaintext
# same configuration: it has a random_password
jq -r '.resources[] | select(.type=="random_password") | .instances[0].attributes.result' terraform.tfstate
Zq8#tR2vLp!mW4xN7cKe9sHd
That password came from a random_password resource (a resource that just generates a random string and remembers it). Its output was marked sensitive = true.
sensitive only hides a value on screen (in plan and apply output). State stores the real value, because Terraform needs it to notice changes. The same goes for storage account access keys, connection strings (a database address plus user and password in one string) and every generated password.
So the state file is a secret, and you handle it like one:
- never in git - a
.gitignorecovers*.tfstate,*.tfstate.*and.terraform/; - never only on a laptop for anything shared - it goes to a remote store with access control (next lesson);
- encrypted at rest (Azure Storage does this by default), versioned (every old copy kept) and with soft delete (a deleted copy can be brought back for some days), so a bad write can be rolled back;
- readable only by the people and pipelines that run Terraform for that environment.
Later (Ch 14): keeping secrets out of state in the first place, with a secret store and references instead of values.
Local state
With no backend configured (next lesson), state is a file next to the code:
terraform.tfstate the current state
terraform.tfstate.backup the previous version, written before every change
Every write first renames the old file to .backup. That gives you exactly one step of undo, on one machine.
For anything a colleague or a pipeline also runs, local state is wrong: nobody else sees it, nothing stops two runs at once, and a lost laptop means lost knowledge of what Terraform owns.
The three rules
- Never commit state. It contains secrets, and git does not stop two people applying from two checkouts at once - you get two different "truths".
- Never edit state by hand. Every change you would want has a command:
state mv,state rm,import, or amoved/removed/importblock (all later in this chapter). Hand edits break the serial and lineage checks and are the fastest way to a state Terraform cannot read. - Never let two runs write it at once. Both read serial 41, both write serial 42, and the second write silently throws away the first. Preventing that is called locking (13.11).
Serial and lineage in practice
# a state that has been written 7 times, before and after one more apply
terraform state pull | jq .serial
7
terraform apply -auto-approve
...
terraform state pull | jq .serial
8
-auto-approve skips the "Enter a value: yes" prompt. The serial went up by one because the apply wrote state once.
Terraform checks the serial in two places you will meet:
- A saved plan (
plan -out, 12.24) records the serial it was made against. If state has moved on since,terraform apply tfplanrefuses: Saved plan is stale. terraform state push(upload a state file, 13.15) refuses to upload an older serial over a newer one, and refuses a different lineage entirely:
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..."
Both are safety nets against the classic accident: restoring an old copy over a newer truth.
One state per environment, and not too big
A state file is a blast radius: everything one bad apply can damage. Everything in it can be touched by one apply, every plan reads all of it, and everyone who can apply it can change all of it.
- Separate state per environment - dev must not be able to break prod.
- Split big states by lifecycle and ownership: the network changes monthly, apps change daily; the platform team owns the shared network, product teams own their own parts.
- Signs a state is too big: plans take minutes, unrelated teams wait for each other, and a typo in one app's code shows up in the network's plan.
What you can now do:
- say what state is and the four reasons it exists
- read a state file with
terraform state pull | jqand explain each field - explain why state is a secret and where it must not live