Skip to main content
RunBook Academy

TerraformIX · State: The Core Production ConceptProduction Terraform

State File Structure

Foundation⏱ ~12 minbash

What you'll learn

  • Identify the top-level fields of a Terraform state file (version, serial, lineage, terraform_version, outputs)
  • Read a resource block in state and map it back to configuration
  • Recognise when state metadata is inconsistent (serial mismatch, lineage fork)
  • Explain why the state format version is fixed and what upgrades it

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

A Terraform state file is a single JSON document. It is not a database. It is not a binary blob. It is plain JSON that an operator can read with jq — and that, in production, is exactly what makes it both transparent and dangerous. The format is stable across versions; the schema_version per resource type changes when the resource schema changes.

A real state file

terraform state pull | jq 'del(.resources)' > state-meta.json
terraform state pull | jq '.resources[0]' > state-first-resource.json

The full document (top-level keys) is:

{
  "version": 4,
  "terraform_version": "1.9.8",
  "serial": 27,
  "lineage": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "outputs": {
    "vpc_id": {
      "value": "vpc-0123456789abcdef0",
      "type": "string"
    }
  },
  "resources": [
    { "module": "module.network", "mode": "managed", "type": "aws_vpc", "name": "main", "instances": [ { "schema_version": 1, "attributes": { "id": "vpc-0123456789abcdef0", "cidr_block": "10.0.0.0/16" } } ] },
    { "mode": "managed", "type": "aws_instance", "name": "web", "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]", "instances": [ { "schema_version": 1, "attributes": { "id": "i-0a1b2c3d4e5f6a7b8", "ami": "ami-0c55b159cbfafe1f0" } } ] }
  ],
  "check_results": null
}

The top-level fields are fixed by the format. The resources array is open-ended — one entry per resource block in configuration.

What each top-level field means

version: 4 — the state format version. This is the version of the JSON document structure itself, not the resource schema version. Version 4 is the current format, in use since Terraform 0.12. Earlier formats (1, 2, 3) are no longer supported. When the format changes, Terraform performs an automatic upgrade on the next command.

terraform_version: "1.9.8" — the version of Terraform that last wrote the state. Recorded so that terraform plan can refuse to run if the local CLI is incompatible. Different Terraform major versions will reject the state outright; same-major, different-minor versions usually work but log a warning. OpenTofu writes its own version string.

serial: 27 — incremented by one on every state write. The first state write produces serial 1. A successful apply increments to 28. A failed apply that did not mutate state leaves it unchanged. Serial is the conflict-detection token: if two operators write concurrently, one of them will see a serial mismatch and fail.

lineage: "<uuid>" — assigned once at initial creation and never changed. Used to detect “forked” state lineages — a state that was intentionally deleted and re-created, or accidentally re-created from an old backup. If two state files share an address but different lineages, the second is a different lineage and Terraform will refuse to use it without explicit recovery.

outputs — the values of every output block declared in configuration, plus their types. Updated on every apply. Available to other configurations via terraform_remote_state and to operators via terraform output.

resources — the per-resource entries. The structure of each entry is described below.

check_results — the results of terraform check blocks (declarative validation rules). Null in most states.

Inside a resource entry

Each entry in resources represents one resource block in configuration. The keys:

{
  "module": "module.network",
  "mode": "managed",
  "type": "aws_vpc",
  "name": "main",
  "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
  "instances": [
    {
      "index_key": null,
      "schema_version": 1,
      "attributes": { "id": "vpc-0123456789abcdef0", "cidr_block": "10.0.0.0/16" },
      "private": "eyJlbm91Z2ggdG8gc2VlIGhlcmUi",
      "dependencies": [
        "aws_internet_gateway.igw"
      ]
    }
  ]
}
  • module — the module path from the root. Empty string for resources in the root module. A nested module path like module.network.module.subnets records the path Terraform will use to address this resource.
  • mode — managed for resources, data for data sources.
  • type — the resource type: aws_instance, aws_vpc, module.call_xyz for outputs of child modules.
  • name — the local name from the configuration block.
  • provider — the fully-qualified provider key. Used by Terraform to associate the resource with the provider instance in the configuration.
  • instances — one entry per instance. Most resources have one instance; resources with count or for_each have many.
  • schema_version — the version of the resource schema this state was written against. When the provider author updates the schema, Terraform runs a migration block to upgrade older states. A schema_version bump does not break older Terraforms — they read the data as-is.
  • attributes — the last-known attribute values. id is the real-world identifier. Other attributes are whatever the schema defines.
  • private — an opaque blob used by some providers for bookkeeping. Not human-readable.
  • dependencies — the resource addresses this resource depends on. Used for plan ordering.

Reading a state file safely

terraform state pull | jq '.serial, .lineage, .terraform_version'

Output:

27
"a1b2c3d4-e5f6-7890-abcd-ef1234567890"
"1.9.8"

A single command, read-only, that confirms three things: the state backend is reachable, the state is parseable JSON, and the format is the expected one. Any anomaly here is worth investigating before running an apply.

For per-resource detail:

terraform state list
terraform state show 'aws_instance.web'

The state-explore lesson covers the read commands in detail. The short version: state list enumerates resource addresses; state show returns the full block for one address; state pull returns the entire JSON.

Why the structure matters

Three production consequences fall out of the structure:

Schema migrations are gated on schema_version. When a provider changes an attribute (say, aws_instance gains a new optional attribute), the schema_version bumps and an existing state needs the migration to be re-read. This happens automatically; you only see it when an old state file fails a terraform plan.

Lineage protects against accidental state replacement. If an operator accidentally re-initialises a backend against a fresh state, the lineage UUID changes. The next terraform plan will report the lineage change and refuse to proceed without operator confirmation. This is the production control against “we accidentally created a brand-new state pointing at the same cloud account”.

Serial detects concurrent writes. If two operators race past the lock (or the lock backend fails open), one of them will see a serial mismatch on commit. Terraform refuses the second write and reports it. The operator then runs terraform plan again to re-read the merged state.

Validation

READ-ONLY

Verify the structure is what you expect:

terraform state pull | jq '
  {
    format_version: .version,
    serial: .serial,
    lineage_present: (.lineage != null),
    resource_count: (.resources | length),
    output_count: (.outputs | length),
    tf_version: .terraform_version
  }
'

Output (illustrative):

{
  "format_version": 4,
  "serial": 27,
  "lineage_present": true,
  "resource_count": 142,
  "output_count": 3,
  "tf_version": "1.9.8"
}

A non-zero resource_count, a non-null lineage, and a serial that matches the last known good value are the three signals that the state is intact. A null lineage is a red flag; a serial that has not incremented in days while applies have run is also a red flag.

Production failure modes

Symptom: “Error: state format version 5 is not supported”. Cause: a much newer Terraform wrote the state and an older CLI is reading it. The format version only goes up on incompatible changes. Recovery is to upgrade Terraform to a version that supports the new format. There is no downgrade path.

Symptom: “Error: state lineage does not match” on a fresh init. Cause: the backend has been re-initialised against a different state, or a state from a different lineage was restored from backup. Recovery is explicit: terraform init -reconfigure after the team agrees which lineage is canonical.

Symptom: serial jumps (e.g. 27 → 53) with no operator action. Cause: a concurrent write got past the lock, or a failed apply left serial advanced in error. Investigate; do not proceed with apply. The next plan should reconcile against a fresh refresh.

Symptom: schema_version mismatch warning in plan output. Cause: the state was written by an older provider plugin and the running plugin has a newer schema. The plan will offer to migrate; review the diff carefully before applying — migrations can change computed attributes.

Symptom: a resource block has no id attribute. Cause: a half-applied resource: Terraform wrote the state record but the provider API never returned a real-world ID. Inspect the cloud console directly. This is rare with remote backends but happens with stateful local failures.

Recovery

  1. Do not edit the state JSON. Every recovery goes through a CLI command or a verified restore from a versioned backup.
  2. For lineage / serial anomalies, the recovery is to pull the last good state from the backend’s version history (S3 versioning, Terraform Cloud versioned state).
  3. For schema version mismatches, run terraform plan once to let Terraform attempt an automatic migration. Review the plan carefully before applying.
  4. Verify the recovery with terraform plan (read-only).

What comes next

The next lesson covers how configuration maps to state — the resource address syntax, the module path, and the discipline of keeping addresses stable across refactors.

Verification

  • You can name the six top-level fields of a state file and explain what each is for.
  • You can read a resource block in state and identify the module, type, name, and provider key.
  • You can pull state, parse it with jq, and verify the serial, lineage, and resource count.
  • You can explain why the state format version is fixed and what schema_version is for.

Knowledge check · 7 questions

  1. Q1. Which top-level field uniquely identifies a state lineage from initial creation?

  2. Q2. What does the serial field do?

  3. Q3. The state file is a fixed-format JSON document whose structure is stable across Terraform versions.

  4. Q4. What is the `schema_version` field inside a resource block?

  5. Q5. Which of the following are top-level fields of a state file? (Select all that apply.)

  6. Q6. What does the `module` field inside a resource block record?

  7. Q7. You run terraform state pull and see serial 53 but the team has only done three applies this week. What is the most likely cause?

Passing score: 75%. Answers are checked in this browser.