Chapter 4: State on GCS
Topic 22

The State File Anatomy

State

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.

The top of a Hatch pipeline state file
{
  "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.

serial vs lineage
serial
A write counter that climbs by one each save; guards against applying onto stale state.
lineage
A UUID stamped once at creation; the fixed identity of this state's history.

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 vs lineage

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.

Common Mistakes
  • Assuming sensitive = true or a sensitive output 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.tfstate to git "because it's just config" — you publish every plaintext secret and a lineage an attacker can correlate; state never goes in version control.
  • Reading a stale local state file and trusting its attributes after someone else applied — the serial moved 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 lineage mismatch or a rewound serial can make the next apply propose destroying resources that still exist.
  • Hand-editing an attribute in the resources array to "correct" a value — the recorded attribute no longer matches GCP, and the next plan fights the live resource instead of converging.
Best Practices
  • Read your state JSON once with terraform show -json | jq to 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-tfstate bucket.
  • Let the backend manage serial and lineage; never hand-edit them, and use state subcommands for any structural change.
  • Keep terraform.tfstate out of git with a .gitignore entry on day one, before the first secret ever lands in it.
Comparable tools Pulumi checkpoint file with the same plaintext-secrets caveat CloudFormation keeps state server-side and opaque gcloud no analog — it tracks nothing

Knowledge Check

What does the serial field protect against, and what does lineage protect against?

  • serial guards against writing onto stale state; lineage proves two state files share the same history
  • serial is the UUID identifying the state's unique origin; lineage is 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
  • serial encrypts the state file at rest; lineage cryptographically signs it

Where in the state JSON are the actual values Terraform read from a resource stored?

  • In the attributes object of each instance inside the resources array
  • In the top-level lineage field alongside the state's identity UUID
  • In a separate .tfvars file 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 serial write counter silently breaks once the file is stored under version control

You got correct