The State File
The idea in one sentence
Section titled “The idea in one sentence”Terraform state is a JSON file that maps every resource in your configuration to the real-world object it created, and it is the only thing that lets Terraform know what it manages.
Why Terraform needs state at all
Section titled “Why Terraform needs state at all”When you run terraform apply, Terraform creates resources through the Azure Resource Manager API — a virtual machine, a storage account, a resource group. The Azure API has no concept of “Terraform.” If your subscription has 500 virtual machines, the Azure API cannot tell you which of those VMs Terraform created for a given resource "azurerm_linux_virtual_machine" "web" block, which ones a teammate created by hand in the Azure Portal, and which ones belong to a completely different Terraform configuration.
Terraform solves this by keeping its own record. Every time you apply, Terraform writes the mapping between your configuration’s resource addresses and the real infrastructure IDs into a state file, by default named terraform.tfstate:
resource "azurerm_linux_virtual_machine" "web" { name = "web-vm" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location size = "Standard_B2s" admin_username = "azureuser"
network_interface_ids = [ azurerm_network_interface.web.id, ]}After apply, the state file records something like azurerm_linux_virtual_machine.web -> /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/example-resources/providers/Microsoft.Compute/virtualMachines/web-vm. That long path is what “the real-world object” actually looks like on Azure — every resource is identified by a fully qualified resource ID that encodes the subscription, the resource group, the provider namespace, and the resource name, all in one string. Alongside that ID, state also caches every attribute Azure returned for the VM — its private IP, its OS disk ID, its network interface associations, and so on. That cached data means Terraform does not have to call the Azure API and re-describe every resource just to compute a plan; it can compare your configuration against the last known state first, which is both how it detects what changed and a real performance win on large configurations. State is also where Terraform stores dependency metadata, so it knows the order in which resources must be created, updated, or destroyed.
The plaintext risk hiding in your state file
Section titled “The plaintext risk hiding in your state file”State files are not sanitized. Any attribute value Terraform receives from a provider — or that you set directly in your configuration — is written into terraform.tfstate as plaintext, including values that are secrets. A common example:
resource "azurerm_postgresql_flexible_server" "main" { name = "app-db" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location version = "15" sku_name = "GP_Standard_D2s_v3" administrator_login = "appadmin" administrator_password = "S3cretPassword!"}You never ran terraform output on that password and you never printed it in a log line. It still lands in terraform.tfstate in plaintext, because Terraform has to remember every attribute it set in order to detect drift on the next plan. Anyone who can read that file can read the password.
This is why terraform.tfstate must never be committed to version control. Add it to .gitignore from the very first commit of a new project:
terraform.tfstateterraform.tfstate.backup*.tfstate*.tfstate.*The next lesson covers moving state off your laptop entirely and into a properly access-controlled remote backend, which is the real fix for this risk — a .gitignore entry only stops the most obvious accident.
Inspecting state
Section titled “Inspecting state”Terraform ships three commands for looking inside state without opening the JSON file by hand:
# Human-readable dump of the entire current stateterraform show
# List every resource address currently tracked in stateterraform state list
# Show full attribute detail for one specific resourceterraform state show azurerm_linux_virtual_machine.webterraform state list is the fast way to answer “what does this configuration actually manage right now” — it prints addresses like azurerm_linux_virtual_machine.web or module.network.azurerm_virtual_network.main with no other detail. terraform state show <address> drills into one of those addresses and prints every attribute Terraform has recorded for it, which is often faster than going to the Azure Portal when you need to check a specific value Terraform is tracking.
Anatomy of a real state file
Section titled “Anatomy of a real state file”The examples below are not reconstructed from memory — they come from a real terraform apply against two resources that need no cloud credentials at all: terraform_data (the built-in provider’s replacement for the deprecated null_resource) and local_file (hashicorp/local). This is the same underlying JSON structure Azure state uses — the format is provider-independent; only the provider string and the resource type/attributes would differ for a real azurerm_* resource. The config:
terraform { required_providers { local = { source = "hashicorp/local" version = "~> 2.5" } }}
resource "terraform_data" "trigger" { input = "v1"}
resource "local_file" "config" { filename = "${path.module}/generated/app.conf" content = "listen=8080\ntrigger_id=${terraform_data.trigger.output}\n"}After terraform init -backend=false && terraform apply -auto-approve, the real terraform.tfstate on disk (machine paths stripped):
{ "version": 4, "terraform_version": "1.16.4", "serial": 6, "lineage": "91789eb2-a206-8e38-2571-62323b581164", "outputs": {}, "resources": [ { "mode": "managed", "type": "local_file", "name": "config", "provider": "provider[\"registry.terraform.io/hashicorp/local\"]", "instances": [ { "schema_version": 0, "attributes": { "content": "listen=8080\ntrigger_id=v1\n", "content_sha1": "7551ae2164513c74b5c0665a6b28b6a40399686f", "filename": "./generated/app.conf", "id": "7551ae2164513c74b5c0665a6b28b6a40399686f" }, "dependencies": ["terraform_data.trigger"], "sensitive_attributes": [[{ "type": "get_attr", "value": "sensitive_content" }]] } ] }, { "mode": "managed", "type": "terraform_data", "name": "trigger", "provider": "provider[\"terraform.io/builtin/terraform\"]", "instances": [ { "schema_version": 0, "attributes": { "id": "7e48645a-6b7e-14c9-d8b3-99d042610145", "input": { "value": "v1", "type": "string" }, "output": { "value": "v1", "type": "string" } }, "sensitive_attributes": [] } ] } ], "check_results": null}Every top-level field means something specific:
version— the state file format version (4 since Terraform 0.12; unrelated to the Terraform binary version).terraform_version— the actual CLI version that last wrote this file.serial— a counter incremented on every write. Two state files claiming the samelineageare compared byserialto decide which is newer.lineage— a UUID generated once, the first time state is created for a working directory. It never changes for the life of that state, and it is how Terraform tells “an older copy of the same state” apart from “an unrelated state file that happens to look similar.”resources[]— one entry per resource block (not per instance —count/for_eachshare one entry with multipleinstances[]), each withmode,type,name,provider, andinstances. For anazurerm_linux_virtual_machine,typewould be"azurerm_linux_virtual_machine"andproviderwould readprovider["registry.terraform.io/hashicorp/azurerm"].instances[].attributes— every attribute the provider returned after create/read, the same dataterraform state showprints.instances[].dependencies— the resource addresses this instance’s config referenced (proven above:local_file.configdepends onterraform_data.triggerbecause itscontentinterpolatesterraform_data.trigger.output). This is exactly the ordering metadata mentioned above — it is why adestroytears things down in the right order without recomputing dependencies from scratch (a VM must be destroyed before the network interface it depends on, for example).
terraform show -json: the same data, provider-agnostic and stable
Section titled “terraform show -json: the same data, provider-agnostic and stable”terraform state show above is a human-readable format meant to be read by a person. terraform show -json is the machine-readable contract other tools (Sentinel, OPA/conftest, custom CI checks) are meant to parse — it is documented, versioned (format_version), and does not change shape between provider versions the way raw state internals sometimes do. Real output (trimmed) from the same apply:
{ "format_version": "1.0", "terraform_version": "1.16.4", "values": { "root_module": { "resources": [ { "address": "local_file.config", "mode": "managed", "type": "local_file", "name": "config", "provider_name": "registry.terraform.io/hashicorp/local", "values": { "content": "listen=8080\ntrigger_id=v1\n", "id": "7551ae2164513c74b5c0665a6b28b6a40399686f" }, "sensitive_values": { "sensitive_content": true }, "depends_on": ["terraform_data.trigger"] } ] } }}drift-detection-and-import.mdx and sensitive-data-and-team-workflows.mdx both come back to show -json, because -detailed-exitcode proofs and the sensitive_values map both live in this exact structure.
Why hand-editing state breaks it: lineage and serial are enforced, not decorative
Section titled “Why hand-editing state breaks it: lineage and serial are enforced, not decorative”terraform state push <file> uploads a local state file to replace whatever the backend currently holds — and it enforces lineage and serial before it will do that, precisely to stop you from clobbering a newer state with a stale or unrelated one. Proof: take the real state above, flip its lineage to a random UUID, and push it back:
$ terraform state push tampered.tfstateFailed to write state: cannot import state with lineage "00000000-0000-0000-0000-000000000000" over unrelated state with lineage "91789eb2-a206-8e38-2571-62323b581164"Same state, serial set backwards instead:
$ terraform state push tampered2.tfstateFailed to write state: cannot import state with serial 1 over newer state with serial 6Both are real refusals from Terraform 1.16.4, not simulated errors. This is exactly why hand-editing terraform.tfstate in a text editor is dangerous even when you only mean to fix one field: unless you also correctly bump serial (and never touch lineage), Terraform’s own consistency check rejects the write the moment you try to push it back — and if you bypass that check by overwriting the backend object directly (skipping state push entirely, e.g. hand-copying a file into an Azure Storage blob), nothing stops you, which is the real failure mode: silent corruption with no error at all, only discovered on the next plan when Terraform’s idea of reality no longer matches the Azure resources actually deployed. terraform state mv, import, and rm all exist specifically so you never need to open the JSON by hand — they update serial correctly as part of the same operation.
What the Azure backend actually stores
Section titled “What the Azure backend actually stores”This course uses a local backend for every proof above (no credentials needed), but production Azure state lives in an azurerm backend. Per the azurerm backend’s own documentation:
“Stores the state as a Blob with the given Key within the Blob Container within the Blob Storage Account.” — developer.hashicorp.com/terraform/language/backend/azurerm
So the state JSON shown above is exactly what lands in that blob — same version/serial/lineage/resources shape, nothing Azure-specific added. Locking is not a second object the way S3 native locking is: “This backend supports state locking and consistency checking with Azure Blob Storage native capabilities” — the azurerm backend takes out a blob lease on the state blob itself for the duration of an operation, rather than writing a separate lock file or lock record. remote-backends-and-locking.mdx covers the full backend block (use_azuread_auth, use_oidc), migration, and force-unlock.
flowchart LR cfg["Terraform config (.tf files)"] --> apply["terraform apply"] apply --> azure["Real Azure resources"] azure -->|attributes written back| state["terraform.tfstate"] cfg -->|next run| plan["terraform plan"] state -->|read for comparison| plan
Mental Model
Section titled “Mental Model”State is Terraform’s only memory of reality — not a cache you can safely discard and not a log you can safely append to by hand. Everything in this lesson follows from one fact: the Azure Resource Manager API has no idea Terraform exists, so every bit of “what did I create, and what did it look like” has to live somewhere Terraform fully controls. lineage and serial are not metadata for humans to read — they are the mechanism that makes state push a safe, checked operation instead of a blind overwrite. Treat the state file the way you’d treat a database’s transaction log: you read it with the tools built for reading it (state show, show -json, state pull), and you mutate it with the commands built for mutating it (state mv, import, rm) — never a text editor.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Caching every attribute in state | plan compares against cached data instead of re-describing every resource from the Azure API — real performance win at scale | Every attribute, including secrets, sits in plaintext on disk or in the backend blob (proven above) |
lineage/serial consistency checks on state push | A stale or unrelated state file cannot silently clobber current state (proven above, twice) | Only protects the state push path — a direct blob overwrite bypasses the check entirely, with no error |
terraform show -json as a stable machine format | Tooling (policy checks, CI, custom scripts) can rely on a documented, versioned schema instead of parsing human-readable output | A second representation of the same data to keep in mind; raw terraform.tfstate internals and show -json are not the same shape |
Common Misconceptions
Section titled “Common Misconceptions”- “State only stores what I put in
outputblocks.” Proven above:local_file.config’s full attribute set — including derived values it never explicitly output — is written intoresources[].instances[].attributesregardless of anyoutputblock. - “I can safely hand-edit
terraform.tfstateas long as I’m careful.” Proven above:state pushrejects alineagechange and aserialregression with a real error. The danger is exactly the case that error doesn’t cover — overwriting the Azure Storage blob directly, which produces no error and no warning, only aplanthat no longer matches reality. - “
serialis the resource count, or a version number I control.” It is an internal write counter Terraform increments itself. You never set it, and setting it to anything other than “higher than the current value” is whatstate pushrefuses in the proof above. - “Azure state locking must use a separate lock file or table, the way S3 uses DynamoDB or a
.tflockobject.” Proven by the azurerm backend’s own docs above: it locks by taking a blob lease directly on the state blob — there is no separate lock resource to provision.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated Terraform that touches state directly, check for:
- A script that opens
terraform.tfstatewithsed/jq -i/a text editor to “fix” one value in place, then relies on the nextapplyto pick it up — proven above, this either gets rejected bystate push’slineage/serialcheck, or worse, silently corrupts the backend blob if the check is bypassed. .gitignoremissing*.tfstate*entirely, or only listingterraform.tfstateand missingterraform.tfstate.backupand the*.tfstate.*(workspace) variants.- Code or a runbook that assumes
serialis something safe to set to a specific number (“set serial to 1 to reset”) — it is a monotonic write counter enforced by the backend, not a field to hand-pick. - A custom drift-checking script that reads
terraform.tfstatedirectly with a hardcoded local path instead ofterraform state pull, which breaks the moment the project switches to a remoteazurermbackend. - Assuming Azure state locking needs a separate DynamoDB-style lock table — proven above, the azurerm backend uses a blob lease on the state blob itself, no extra resource to provision.
🐛 Spot the bug
#!/usr/bin/env bash# "quick fix" for a wrong VM size recorded in statesed -i '' 's/"Standard_B2s"/"Standard_B4ms"/' terraform.tfstateterraform state push terraform.tfstateShow the answer
Two real problems, proven above. First, state push enforces lineage/serial — but sed -i here doesn’t change either field, so the push might actually succeed, which is the trap: Terraform now believes the VM’s size is Standard_B4ms while Azure still has a Standard_B2s VM running. Nothing calls the Azure API to verify. The next plan will show no change (state now falsely agrees with the edited value), so drift between the edited state and the real VM goes completely undetected until something else surfaces it — a bill that doesn’t match, or someone checking the portal. The correct fix for “state disagrees with reality” is never a hand edit — it’s terraform apply (if the config should win), terraform plan -refresh-only (to see what changed), or terraform import/state rm (covered in drift-detection-and-import.mdx), all of which go through Terraform’s own consistency machinery instead of bypassing it.