The State File Anatomy
The state file is plain JSON, and reading it once demystifies most of Terraform's behavior. It is a top-level object with a version, a terraform_version, a monotonic serial, a random lineage, and a resources array — and inside that array, every attribute Terraform recorded, including, in plaintext, any secret the config touched.
You almost never edit it by hand, but you should know its shape, because nearly every confusing thing Terraform does has an explanation visible in the JSON. When a plan insists a resource needs replacing, when a restored backup proposes destroying live infrastructure, when a teammate's apply seems to vanish — the state structure is where the answer lives.
The Top-Level Fields
Four header fields sit above the resource list. version is the state schema version, which the binary uses to know how to read the file. terraform_version records which binary last wrote it, so an older Terraform refuses to silently downgrade newer state. serial increments by one on every write. lineage is a random UUID stamped once at creation and never changed. Together, these last two are how Terraform detects stale or mismatched state.
{
"version": 4,
"terraform_version": "1.9.0",
"serial": 42,
"lineage": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"resources": [
{
"type": "google_storage_bucket",
"name": "raw",
"instances": [
{
"attributes": {
"name": "hatch-events-raw",
"self_link": "https://www.googleapis.com/storage/v1/b/hatch-events-raw",
"location": "US"
}
}
]
}
]
}
Reading top to bottom: this is schema version 4, written by Terraform 1.9.0, on its 42nd write, belonging to one specific lineage, holding one resource. The shape is the same whether the file tracks one bucket or a thousand resources.
resources → instances → attributes
The resources array is the heart of the file. Each entry names a resource type and address — google_storage_bucket.raw — and carries an instances list, because a single resource block can produce many instances under count or for_each. Each instance holds an attributes object: the actual values Terraform last read from GCP.
Those attributes are the cached reality. For the Hatch raw bucket, the attributes include its name, its self_link, its id, its url, and every other field the provider exposes. When a plan says the bucket needs no change, it is comparing your config against exactly these recorded attributes — which is why a hand-edit here desyncs the map and produces a plan that fights the live resource.
serial and Concurrency
serial is a monotonic counter that rises by one every time state is written. Its job is concurrency safety: a backend reads the serial it started from, and if state on the backend has advanced past it by the time the write lands, the write is rejected as stale. This is part of how two engineers do not silently clobber each other — a write built on an old serial is refused rather than overwriting newer work.
A rising serial is completely normal; it is simply the count of how many times this state has changed. What is not normal is a serial that goes backwards, which is exactly what happens when you restore an old backup — and Terraform treats that as a signal something is wrong.
lineage and Identity
lineage is a random UUID generated once when state is first created and then carried unchanged for the life of that state. Where serial answers "how recent is this," lineage answers "is this even the same state." It ties a state file to its own history, so Terraform can tell whether two files are versions of one lineage or two entirely unrelated states.
This is the guard against pointing at the wrong state. Restore a backup from a different config, or accidentally configure the backend to a stale object, and a lineage mismatch is how Terraform tells you the state is not the one this config grew up with — before it does something destructive based on a foreign record.
Secrets in Plaintext
The most important property of state is also the most dangerous. When a resource has a sensitive attribute — a google_secret_manager_secret_version's payload, a generated database password, a TLS private key — Terraform stores that value verbatim in the JSON, because state records the attributes it read and there is no setting that strips them out.
The sensitive flag does not change this. It hides the value from plan and apply output so it does not leak into CI logs, but it does nothing to the bytes on disk. The secret sits in the state file in plaintext regardless, which is the entire reason the state bucket — not the flag — is what actually protects it. That makes the whole file a secret, and it is why state never goes in git.
serial — a version counter that increments on every write and guards against applying onto stale state. A changed serial is normal; it just means state has moved on. A serial that has gone backwards means a backup was restored.
lineage — a fixed identity stamped once at creation that proves two state files share the same history. A changed lineage is never normal: it means you are looking at a different state lineage entirely, not a newer version of the same one.
- Assuming
sensitive = trueor asensitiveoutput encrypts the value in state — it only redacts CLI output; the secret sits in the JSON in plaintext and the state bucket is what actually protects it. - Committing a local
terraform.tfstateto git "because it's just config" — you publish every plaintext secret and alineagean attacker can correlate; state never goes in version control. - Reading a stale local state file and trusting its attributes after someone else applied — the
serialmoved on the remote backend and your copy is behind, so your plan is built on an old picture. - Restoring an old state backup blindly after a mistake — a
lineagemismatch or a rewoundserialcan make the next apply propose destroying resources that still exist. - Hand-editing an attribute in the
resourcesarray to "correct" a value — the recorded attribute no longer matches GCP, and the next plan fights the live resource instead of converging.
- Read your state JSON once with
terraform show -json | jqto internalize the resources / instances / attributes shape before you ever need to debug it. - Treat the entire state file as a secret because it contains plaintext secrets — store it only in an access-controlled, encrypted backend like the
hatch-tfstatebucket. - Let the backend manage
serialandlineage; never hand-edit them, and usestatesubcommands for any structural change. - Keep
terraform.tfstateout of git with a.gitignoreentry on day one, before the first secret ever lands in it.
Knowledge Check
What does the serial field protect against, and what does lineage protect against?
serialguards against writing onto stale state;lineageproves two state files share the same historyserialis the UUID identifying the state's unique origin;lineageis the integer counting the total number of writes the file has received- Both are random UUIDs that exist purely for human-readable display in the file
serialencrypts the state file at rest;lineagecryptographically signs it
Where in the state JSON are the actual values Terraform read from a resource stored?
- In the
attributesobject of each instance inside theresourcesarray - In the top-level
lineagefield alongside the state's identity UUID - In a separate
.tfvarsfile that the state JSON references by path - Only inside the provider's own remote attribute cache, never within state
You mark a database password output sensitive. What does that actually do to the secret in state?
- Nothing — it redacts the value from CLI output and logs, but the plaintext still sits in the state file
- It encrypts just that one attribute in place inside the state file using the provider's own private key, while every other attribute stays in plaintext
- It strips the value out of the state file entirely once the apply completes
- It automatically relocates the value into Secret Manager and leaves a reference behind
Why must terraform.tfstate never be committed to git?
- It contains every secret the config touched in plaintext, so committing it publishes them all
- Git simply cannot store JSON files larger than a few kilobytes without corrupting them, and a state file always exceeds that hard limit
- Terraform flatly refuses to read any state file that sits inside a tracked git history
- The
serialwrite counter silently breaks once the file is stored under version control
You got correct