Skip to content
beginner Phase 5 · State Storage and Remote Backends

State File Internals

Reading the JSON structure of state with resources, outputs, serial, and lineage.

50m
0 problems
Topic Progress 0%

Opening terraform.tfstate: What the Top-Level Fields Actually Do

Terraform's state is not a mystery file — it is a JSON document you can read with any text editor, and understanding its skeleton explains most of the behavior people find surprising later. A minimal state file looks like this:

{
  "version": 4,
  "terraform_version": "1.9.5",
  "serial": 27,
  "lineage": "3f2b8c1a-9d44-4c6e-8a10-77f0d2e5b9c3",
  "outputs": {
    "vpc_id": {
      "value": "vpc-0abc1234",
      "sensitive": false
    }
  },
  "resources": [ ... ],
  "check_results": null
}

Field by field:

  • version is the state format version, not your Terraform version. It currently sits at 4; when it changes, older Terraform releases refuse the file rather than misinterpret it. This is the schema version of the JSON itself.
  • terraform_version records the release that last wrote the file. When a colleague's plan behaves oddly, this line plus serial is the first forensic fact: which binary last touched state.
  • serial is a monotonically incrementing write counter. Every successful state write bumps it by one. Backends compare serials to detect lost updates: if your in-memory view says 26 and storage says 27, someone else wrote between your read and your write, and a backend with real locking or conditional writes refuses to let you clobber their change.
  • lineage is a random UUID generated the first time the state is created and then kept forever. It identifies this state's origin — two files with the same lineage are copies of each other, while a different lineage means you are looking at a different state history entirely. Together, serial and lineage are how Terraform distinguishes "the next write in this history" from "an unrelated file someone pasted over mine."
  • outputs caches the root module's output values after each apply, so tooling that reads state — terraform output, a CI step consuming terraform_remote_state, an external script — does not need to re-evaluate configuration or contact providers.
  • resources is the payload: every managed resource and data source, keyed by address, with full attributes. It gets its own treatment next.
  • check_results carries results of check blocks (Terraform 1.5+), which assert health conditions without blocking operations.

Two absences are as instructive as the contents. Input variables are not here — they were inputs, not infrastructure. Locals are not here either, for the same reason: state records outcomes, and a derived configuration value is not an outcome. That is why editing a local never requires a state edit, while renaming a resource does.

Location and companions matter operationally. With local state the file is terraform.tfstate in the working directory, and after each write Terraform keeps a sibling terraform.tfstate.<timestamp>.backup — a free undo point that has rescued more than one botched state operation. While a plan or apply is running, a short-lived .terraform.tfstate.lock.info sits beside it recording who holds the lock (operating-system user, hostname, PID, timestamp); the absence of that file is how you confirm no local operation is mid-flight before starting surgery. Remote backends replace all three with their own storage and locking scheme, but the concepts — current state, last backup, active lock — persist in every backend you will meet.

One practical consequence of the JSON being authoritative: manual editing is possible and almost always a mistake. Terraform's own tools — terraform state mv, state rm, import, moved blocks — rewrite entries while preserving format invariants (serial bumps, lineage retention, attribute shapes). A hand edit with a JSON editor is how teams end up with a state file that no longer parses or, worse, one that parses and silently mismatches the real cloud. When you need to inspect, use terraform state list and terraform show; when you need to change, use the commands — that is exactly the territory of Listing and Showing State Entries and beyond.

Inside a Resource Entry: Addresses, Instances, and the Attribute Cache

Each element of resources is one address in your configuration, and each one carries the mapping from Terraform's vocabulary to the real object plus every attribute the provider reported. A trimmed but faithful entry:

{
  "mode": "managed",
  "type": "aws_instance",
  "name": "web",
  "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
  "instances": [
    {
      "schema_version": 1,
      "attributes": {
        "id": "i-0abc1234def56",
        "ami": "ami-0abcdef1234567890",
        "instance_type": "t3.micro",
        "tags": { "Name": "web-0" },
        "private_ip": "10.0.1.15"
      },
      "sensitive_attributes": [],
      "private": "bnVsbA=="
    }
  ]
}

Read the fields in order of what they tell you. mode is either managed (something you create and destroy) or data (something you read and refresh) — a surprise for newcomers who grep state and find entries they never wrote a resource for. type and name come straight from your block; together with the optional module path and instance key they form the address used everywhere else (module.app.aws_instance.web[0]). provider records the exact provider binary responsible, including its registry source, which is why a state written by a forked provider cannot be casually adopted by the official one.

instances is a list because one block can produce several objects: count and for_each materialize as separate instances, and each instance's position or key is stored alongside its attributes (an index_key for maps, a numeric index for count-style). A resource replaced mid-apply can even appear with a deposed key — the marker of the old object Terraform is still tracking while tearing it down.

The attributes object is the heart: a full cache of the last-known state of the real object, written when the object is created or updated and rewritten on refresh from what the provider reads back. When you see a diff in terraform plan, Terraform is comparing this cached object against the newly computed desired object. That framing answers a classic interview question: what does plan actually do? It reads state, refreshes the attributes from the provider (unless -refresh=false), diffs cached-vs-desired, and proposes operations — state is the baseline for every diff you have ever reviewed.

To read an entry without opening the file, terraform state show aws_instance.web renders one resource's stored attributes; for machine consumption, terraform show -json plan.out piped to a jq filter over .values.root_module.resources[] by address extracts the same data from a saved plan. Both read the cache — which means both can be stale relative to the cloud until a refresh runs. That refresh-versus-cache distinction is the origin of the word drift: change an object in the AWS console and the cached attributes stop matching reality; the next plan's refresh rewrites the cache from what the provider reads, making the drift visible as a diff. -refresh=false skips that step and diffs against the stale cache — occasionally useful in narrowly scoped pipelines, dangerous when you believe the plan reflects the world.

schema_version is per-resource-type bookkeeping: providers store it so they can run upgrade logic on the cached attributes when their schema evolves. sensitive_attributes lists paths that were marked sensitive, letting the CLI mask them on display. private is an opaque base64 blob the provider uses for its own bookkeeping — you do not read it, and you must not edit it.

Data sources appear with the same shape under mode = "data", which has a security implication: anything a data block read — an SSM parameter, a secrets-manager entry the provider returned — lands in this attribute cache too. That is a direct thread into the third chapter: what state contains, and why that dictates who may read the file.

Outputs, Sensitive Values, and Why State Is Handled Like a Secret

The outputs section is a cache of the root module's exported values, and it exists for consumers outside the run that produced them:

"outputs": {
  "vpc_id":    { "value": "vpc-0abc1234", "sensitive": false },
  "db_password": {
    "value": "hunter2-rotated-last-tuesday",
    "sensitive": true
  }
}

After every apply, Terraform writes the current output values here so terraform output, CI pipelines, and terraform_remote_state data sources can read them without evaluating your configuration or talking to providers. A remote-state consumer in another directory gets exactly these cached values — which is why an output only updates when the producing stack is applied, not when its configuration changes.

Now the part that decides how you must operate. sensitive: true changes nothing about storage. The password above is written in clear text, in JSON, right there in the file. The flag only instructs the CLI to render the value as (sensitive value) in command output. Every mechanism from earlier topics follows the same rule: sensitive = true on an input variable masks plan output; a sensitive data source marks its result — but state receives the underlying value regardless.

Run the numbers and the risk becomes concrete. A state file for a modest AWS account routinely contains: RDS master passwords in master_user_password attributes, generated key material, and the contents of any data source that returned a secret. Add the fact that state is an inventory — every resource ID, subnet, IAM role name — and you have a map of the estate in one file. Treat reading state as equivalent to holding broad production credentials, because functionally it is.

The controls that follow from that framing are organizational, not cryptographic on Terraform's side:

  • Restrict read access to the state store itself — bucket policies and IAM on S3, row-level access on whatever backend you use.
  • Encrypt at rest with a customer-managed key, so the platform operator cannot casually read your secrets either.
  • Never commit state to version control; .gitignore the local file, and remember git history retains deleted copies.
  • Keep real secrets out of state where possible — read them from a secret manager at apply time and let the consuming resource fetch them, rather than passing them as stored arguments.
  • Audit who can assume what to reach the state backend; that is the boundary everyone forgets when the frontend's IAM role is tight but the state bucket is broad.

Two more facts close the loop for teams. First, terraform_remote_state data sources read another stack's entire state through the backend — granting a module that data source means granting the consuming pipeline read access on the producing stack's state store. Remote state is a deliberate cross-stack trust relationship, not a convenience call: if a consumer should only ever receive three values, expose them through a narrow channel — a parameter-store entry, an outputs-only stack — instead of handing over the whole inventory.

Second, plan output and state have different audiences, and the difference is the whole security story. Plan output is designed to be pasted into pull requests and CI logs, so it masks sensitive values. State is designed to be read by Terraform alone, and it masks nothing. The discipline that follows is simple: anything ending up in state inherits the classification of the most sensitive value inside it, which for most production stacks means secret. Teams that treat the state bucket as "just a file" are the ones whose credentials resurface in a CI log archive; teams that treat it as a secrets store with an inventory attached apply the controls above as routine.

This chapter treated state as a document; the next treats it as a liability — Limitations of Local State Files is where the document meets teams, CI runners, and lost laptops.

Quiz

1. What do the serial and lineage fields in state protect against?

Question 1 options

2. You find `mode: "data"` entries in your state file. What are they?

Question 2 options

3. An output is marked "sensitive": true in state. Where is the actual value stored, and what does the flag do?

Question 3 options

4. A teammate edits terraform.tfstate in a JSON editor to fix a wrong tag. What is the primary danger?

Question 4 options

Flashcards

Question

What are the top-level sections of a Terraform state file and what is each for?

Answer

version (JSON format schema, currently 4), terraform_version (release that last wrote it), serial (write counter for lost-update detection), lineage (UUID identifying this state's history), outputs (cached root outputs for external consumers), resources (managed + data entries with full attribute caches), and check_results (check block results, 1.5+).

Question

What does terraform plan actually do with state?

Answer

Reads the cached attributes from state, refreshes them against the provider (unless -refresh=false), computes the desired object from configuration, and proposes the operations that move reality toward the desired object. State is the baseline for every diff.

Question

What lives in a resource entry's instances array?

Answer

One element per materialized object: for count/for_each, each index or key with its own attributes, schema_version, sensitive_attributes, and the private base64 provider blob; a mid-replacement object may carry a deposed key for the old instance being torn down.

Question

Why does the outputs section exist, and when do remote-state consumers see updates?

Answer

It caches the root module's output values so terraform output, CI, and terraform_remote_state can read them without evaluating configuration or contacting providers. Consumers see new values only when the producing stack is applied — not when its config merely changes.

Question

Are sensitive values stored in state, and what is the resulting security posture?

Answer

Yes — plaintext. sensitive only masks CLI display. State therefore holds passwords, generated keys, and any secret a data source returned, plus a full resource inventory; treat read access to state like broad production credentials: restrict IAM, encrypt at rest with a CMK, keep it out of git, and prefer referencing secret managers over storing secrets.

Revision Notes

Key Takeaways

  • 1. State is readable JSON with a small header (version, terraform_version, serial, lineage) and two payload sections, resources and outputs
  • 2. serial counts writes and lineage stamps origin — together they let backends detect conflicting writes and unrelated files
  • 3. Every resource and data source is stored with a full attribute cache, which is the baseline plan diffs against after refresh
  • 4. Outputs are cached for external tooling, so remote-state consumers update only when the producing stack is applied
  • 5. sensitive marks display only: state carries plaintext secrets, making state-store access control the real security boundary

Interview Tips

  • • "What is in the state file?" — walk the skeleton: format version, terraform_version, serial, lineage, outputs cache, then resources with mode/type/provider/instances/attributes; mentioning mode=data shows you know data sources are stored too
  • • "What does terraform plan do?" — read state, refresh attributes, diff cached vs desired, emit an operation list; this answer demonstrates you understand state as the diff baseline rather than a mystery artifact
  • • "Is a sensitive value safe in state?" — no: plaintext storage, masking is display-only; pivot to controls (IAM on the store, CMK encryption, secret-manager references) — that pivot is what interviewers are listening for
  • • "How does Terraform detect a concurrent write?" — serial increments per write and backends compare it (with locking/conditional writes), while lineage distinguishes this history from an unrelated file — a strong answer on why backends are not just file hosting

Cheat Sheet

State File Internals

Top level: version (format=4) · terraform_version (last writer) · serial (write counter; lost-update detection) · lineage (origin UUID, kept forever) · outputs (cached root outputs) · resources (payload) · check_results (check blocks, 1.5+).

Resource entry:

{ "mode": "managed|data", "type": "aws_instance", "name": "web",
  "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
  "instances": [ { "schema_version": 1, "attributes": { "id": "..." },
                   "sensitive_attributes": [], "private": "..." } ] }
  • count/for_each → multiple instances (index_key); replacement → possible deposed key
  • attributes = provider's last-known cache; refresh rewrites them; plan diffs cached-vs-desired
  • data sources are stored too (mode: data) — their reads land in state as well

Not in state: input variables, locals — state records outcomes, not configuration.

Security: sensitive: true masks display only; values are plaintext in JSON. Controls: tight read IAM · CMK encryption at rest · never in git · reference secret managers instead of storing secrets · audit who can reach the state store.

Continue Learning