Skip to content

The State File

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.

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:

.gitignore
terraform.tfstate
terraform.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.

Terraform ships three commands for looking inside state without opening the JSON file by hand:

Terminal window
# Human-readable dump of the entire current state
terraform show
# List every resource address currently tracked in state
terraform state list
# Show full attribute detail for one specific resource
terraform state show azurerm_linux_virtual_machine.web

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

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:

main.tf
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 same lineage are compared by serial to 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_each share one entry with multiple instances[]), each with mode, type, name, provider, and instances. For an azurerm_linux_virtual_machine, type would be "azurerm_linux_virtual_machine" and provider would read provider["registry.terraform.io/hashicorp/azurerm"].
  • instances[].attributes — every attribute the provider returned after create/read, the same data terraform state show prints.
  • instances[].dependencies — the resource addresses this instance’s config referenced (proven above: local_file.config depends on terraform_data.trigger because its content interpolates terraform_data.trigger.output). This is exactly the ordering metadata mentioned above — it is why a destroy tears 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.tfstate
Failed 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.tfstate
Failed to write state: cannot import state with serial 1 over newer state with serial 6

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

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
Config and real Azure resources both feed the state file that the next plan reads

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.

ChoiceBenefitCost
Caching every attribute in stateplan compares against cached data instead of re-describing every resource from the Azure API — real performance win at scaleEvery attribute, including secrets, sits in plaintext on disk or in the backend blob (proven above)
lineage/serial consistency checks on state pushA 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 formatTooling (policy checks, CI, custom scripts) can rely on a documented, versioned schema instead of parsing human-readable outputA second representation of the same data to keep in mind; raw terraform.tfstate internals and show -json are not the same shape
  • “State only stores what I put in output blocks.” Proven above: local_file.config’s full attribute set — including derived values it never explicitly output — is written into resources[].instances[].attributes regardless of any output block.
  • “I can safely hand-edit terraform.tfstate as long as I’m careful.” Proven above: state push rejects a lineage change and a serial regression 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 a plan that no longer matches reality.
  • “serial is 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 what state push refuses in the proof above.
  • “Azure state locking must use a separate lock file or table, the way S3 uses DynamoDB or a .tflock object.” 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.

When reviewing generated Terraform that touches state directly, check for:

  • A script that opens terraform.tfstate with sed/jq -i/a text editor to “fix” one value in place, then relies on the next apply to pick it up — proven above, this either gets rejected by state push’s lineage/serial check, or worse, silently corrupts the backend blob if the check is bypassed.
  • .gitignore missing *.tfstate* entirely, or only listing terraform.tfstate and missing terraform.tfstate.backup and the *.tfstate.* (workspace) variants.
  • Code or a runbook that assumes serial is 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.tfstate directly with a hardcoded local path instead of terraform state pull, which breaks the moment the project switches to a remote azurerm backend.
  • 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 state
sed -i '' 's/"Standard_B2s"/"Standard_B4ms"/' terraform.tfstate
terraform state push terraform.tfstate
Show 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.

Why does Terraform need a state file instead of just querying the Azure Resource Manager API each time
A database password was set as a resource attribute but was never printed with terraform output. Can it still end up readable in plain text
A hand-edited state file like this is pushed with terraform state push tampered.tfstate against a backend currently at serial 6, lineage 91789eb2-.... What happens?
{
  "serial": 1,
  "lineage": "00000000-0000-0000-0000-000000000000"
}
What does the dependencies field on a state resource instance actually record, proven in the local_file.config / terraform_data.trigger example above?
What is the difference between terraform state list and terraform state show
How does the azurerm backend implement state locking, according to its own documentation?