Skip to content

Plan File and JSON Output

terraform plan -out=tfplan writes a real, self-contained zip archive to disk, and terraform show -json turns it (or the current state) into a stable, documented JSON schema whose resource_changes[].change.actions array — not the human-readable text summary — is the real interface policy engines, cost tools, and CI scripts are meant to read.

Using this module’s own graph demo config:

$ terraform plan -out=tfplan.bin -input=false
...
Saved the plan to: tfplan.bin
$ file tfplan.bin
tfplan.bin: Zip archive data, at least v2.0 to extract, compression method=deflate
$ unzip -l tfplan.bin
Archive: tfplan.bin
Length Date Time Name
--------- ---------- ----- ----
1276 09-29-2026 04:05 tfplan
3048 09-29-2026 04:05 tfstate
3012 09-29-2026 04:05 tfstate-prev
504 09-29-2026 04:05 tfconfig/m-/main.tf
41 09-29-2026 04:05 tfconfig/modules.json
2408 09-29-2026 04:05 .terraform.lock.hcl
--------- -------
10289 6 files

Six real entries inside one file: the actual plan (tfplan), the state before this plan (tfstate-prev) and after the refresh this plan performed (tfstate), a full snapshot of the configuration that produced it (tfconfig/), and the lock file. This is why terraform apply tfplan.bin never re-reads your .tf files or re-refreshes anything by default — everything apply needs is already inside this one archive.

-detailed-exitcode: a real, documented exit code contract

Section titled “-detailed-exitcode: a real, documented exit code contract”
$ terraform plan -input=false -detailed-exitcode -out=noop.bin
...
Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
$ echo $?
0

After editing the config to add a resource and force a replacement, the same flag against the same state:

$ terraform plan -input=false -detailed-exitcode -out=haschanges.bin
...
Plan: 3 to add, 0 to change, 2 to destroy.
$ echo $?
2

0 = no changes, 1 = a real error, 2 = changes present (and, without -detailed-exitcode, plan always exits 0 on success regardless of whether there were changes) — a documented, stable contract for a CI gate to branch on directly, with zero text parsing.

resource_changes[].change.actions: the real matrix, proven

Section titled “resource_changes[].change.actions: the real matrix, proven”

terraform show -json <planfile> is the stable, documented interface. Against a plan that creates one resource, replaces two, and leaves one unchanged:

$ terraform show -json tfplan3.bin | python3 -c "
import json,sys
d = json.load(sys.stdin)
for rc in d['resource_changes']:
print(rc['address'], rc['change']['actions'])
print([rc.get('action_reason') for rc in d['resource_changes']])
"
local_file.extra ['create']
local_file.greeting ['delete', 'create']
random_pet.name ['delete', 'create']
terraform_data.after_file ['no-op']
[None, 'replace_because_cannot_update', 'replace_because_cannot_update', None]

['delete', 'create'] — never a single 'replace' string — is exactly how Terraform represents a destroy-then-recreate, and action_reason (a real field, replace_because_cannot_update here, because random_pet.length is ForceNew) tells you why without parsing the human-readable plan text at all. The documented full set of actions values is:

["no-op"] ["create"] ["read"] ["update"] ["delete"] ["delete", "create"] ["create", "delete"]

A real removed block with lifecycle { destroy = false } (Terraform 1.7+) produces a value not in that documented list — this course found it live and it directly contradicts the docs page as fetched during this lesson’s research:

$ terraform show -json tfplan4.bin | python3 -c "..."
terraform_data.after_file ['forget']

prior_state, configuration, and the other top-level keys

Section titled “prior_state, configuration, and the other top-level keys”
$ terraform show -json tfplan2.bin | python3 -c "import json,sys; print(list(json.load(sys.stdin).keys()))"
['format_version', 'terraform_version', 'planned_values', 'resource_changes', 'prior_state', 'configuration', 'timestamp', 'applyable', 'complete', 'errored']

Per the official JSON format docs: prior_state is “the state that the configuration is being applied to,” in the same state representation show -json (no plan argument) uses (State File Anatomy covers that representation in depth); configuration is “the configuration being applied to the prior state,” a structural (not textual) representation of the .tf files themselves; relevant_attributes (documented, not reproduced in this lesson’s minimal config — it only appears when a replacement is caused indirectly, through an unknown value flowing from another resource) “lists the sources of all values contributing to changes in the plan,” meant for filtering resource_drift against exactly what could have caused it.

Conftest/OPA policies written against Terraform plans are written against exactly this show -json output — a .rego policy iterates input.resource_changes[] and checks .change.actions and .change.after fields directly, the same fields proven above. This is also why CI pipelines run terraform show -json tfplan > plan.json before invoking a policy tool: the tool never runs terraform itself, it consumes the JSON show -json already produced. Infracost works the same way for cost estimation — it consumes resource_changes[].change.after (the planned attribute values) to compute a per-resource cost delta without ever calling a cloud pricing API against your real account.

There are three, structurally different JSON documents in this module, and confusing them is the single most common mistake: raw state (state pull, covered in State File Anatomy — version/serial/lineage, no resource_changes), show -json of the current state (no plan argument — format_version/terraform_version/values only, a snapshot with no change information at all), and show -json of a saved plan file (this lesson — adds resource_changes, prior_state, configuration, planned_values, the actual diff). Only the third has resource_changes[].change.actions; policy and cost tooling need exactly that one.

ChoiceBenefitCost
-out plan file + apply <file>Guarantees apply performs exactly what was reviewed — no re-evaluation, no new provider RPCs beyond apply itselfThe binary file is stateful and single-use; applying a stale plan file against changed real infrastructure fails safely, but the workflow requires shipping the file through CI
show -json for policy gatesDocumented, stable schema; no text parsing; action_reason explains why, not just whatLarger payload than the human text summary; requires a JSON-capable policy tool
-detailed-exitcodeA real, stable 0/1/2 contract, provable aboveEasy to forget: without it, plan’s exit code does not distinguish “no changes” from “changes present”
  • “A replace shows up as one 'replace' action string.” Proven above: it is always the two-element array ['delete', 'create'] (or ['create', 'delete'] for create-before-destroy) — never a single 'replace' string anywhere in the real schema.
  • “Every possible actions value is in the public docs.” Proven above with a real, contradicting run: ['forget'] (from a removed { lifecycle { destroy = false } } block, Terraform 1.7+) was produced live by 1.16.4 and was not present in the documented list as fetched for this lesson.
  • “terraform plan’s exit code tells you whether there were changes.” Proven above: without -detailed-exitcode, a successful plan always exits 0 whether or not there are changes — -detailed-exitcode is required to get 0/2 distinguished, and 1 still means a real error either way.
  • “A saved plan file is basically a bigger version of the text plan prints.” Proven above with unzip -l: it is a real archive containing two full state snapshots and a full configuration snapshot, not a cached copy of terminal output.

🐛 Spot the bug

# .github/workflows/terraform.yml (relevant step)
- name: Plan
run: terraform plan -out=tfplan
- name: Check for changes
run: |
if terraform show tfplan | grep -q "No changes"; then
echo "clean=true" >> "$GITHUB_OUTPUT"
fi
- name: Cost check
run: infracost breakdown --path tfplan --format json | jq '.totalMonthlyCost'
Show the answer

Two real problems, both proven in this lesson. First, terraform show tfplan (no -json) prints the same human-readable, unstable text the plan command itself prints — grepping "No changes" is exactly the fragile pattern -detailed-exitcode’s real 0/2 contract (proven above) exists to replace, with zero text matching. Second — this one is actually fine and worth recognizing as correct: infracost breakdown --path tfplan really does consume the plan file’s structured data (via the same show -json-shaped resource_changes[].change.after this lesson proved), which is the documented, correct way real cost tools read a Terraform plan — not a script bug, an example of the right interface being used right next to the wrong one.

What does this real output prove about terraform plan -out?
$ file tfplan.bin
tfplan.bin: Zip archive data, at least v2.0 to extract, compression method=deflate
$ unzip -l tfplan.bin
  tfplan
  tfstate
  tfstate-prev
  tfconfig/m-/main.tf
  tfconfig/modules.json
  .terraform.lock.hcl
What is the real difference between resource_changes[].change.actions values ["delete","create"] and a hypothetical single "replace" string?
This lesson found this action value from a real removed { lifecycle { destroy = false } } block. What does this prove about the public JSON format docs?
$ terraform show -json tfplan4.bin | python3 -c "..."
terraform_data.after_file ['forget']
A CI script runs terraform plan -out=tfplan, then greps terraform show tfplan output for "No changes" to decide whether to skip approval. What is the correct, documented alternative, proven in this lesson?
How does a tool like Conftest/OPA or Infracost actually consume a Terraform plan in CI, per this lesson?