TerraformIX · State: The Core Production ConceptProduction Terraform
State File Structure
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
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 likemodule.network.module.subnetsrecords the path Terraform will use to address this resource.mode—managedfor resources,datafor data sources.type— the resource type:aws_instance,aws_vpc,module.call_xyzfor 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 withcountorfor_eachhave many.schema_version— the version of the resource schema this state was written against. When the provider author updates the schema, Terraform runs amigrationblock 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.idis 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
- Do not edit the state JSON. Every recovery goes through a CLI command or a verified restore from a versioned backup.
- 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).
- For schema version mismatches, run
terraform planonce to let Terraform attempt an automatic migration. Review the plan carefully before applying. - 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_versionis for.
Knowledge check · 7 questions
Q1. Which top-level field uniquely identifies a state lineage from initial creation?
Q2. What does the serial field do?
Q3. The state file is a fixed-format JSON document whose structure is stable across Terraform versions.
Q4. What is the `schema_version` field inside a resource block?
Q5. Which of the following are top-level fields of a state file? (Select all that apply.)
Q6. What does the `module` field inside a resource block record?
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.