Planreader

Terraform plan JSON: how to get it and what's in it

A plan in JSON says exactly what Terraform or OpenTofu will do to each resource, which attribute forced each replacement, and what refers to what. The text output leaves some of that out, and its wording isn't a stable format. This page shows how to get the JSON, which parts to read, and how to query it with jq. The examples come from a real plan made with Terraform 1.9.8 and the AWS provider, with invented names.

Paste a plan JSON and see it explained

Save the plan, then show it as JSON

JSON comes from a saved plan file, not from plan itself:

terraform plan -out=tfplan
terraform show -json tfplan > plan.json
tofu plan -out=tfplan
tofu show -json tfplan > plan.json

Run show in the same initialised working directory that made the plan. Two near misses catch people out:

The parts worth reading

FieldWhat it holds
resource_changesOne entry per resource instance, with its address, the planned actions, and the values before and after. This is the part most tools read.
resource_driftChanges made outside Terraform that the refresh found. They aren't actions in this plan, but they often explain it.
output_changesRoot module outputs, keyed by name, with the same actions, before and after.
configurationThe configuration as Terraform understood it, including the expressions each attribute uses. That's where references between resources come from.
relevant_attributesThe resource attributes whose values feed this plan's changes, such as a dead-letter queue's arn in another queue's redrive policy. Use it to tell which drift actually affected the plan.
prior_state, planned_valuesFull snapshots of the state before, and the values expected after. Large, and mostly repeated in resource_changes.
format_version"1.2" from both tools today. A change in the first number means the format broke compatibility.
errored, applyable, completeWhether planning failed part-way, and whether the plan can be applied and covers everything. OpenTofu 1.13.2 writes errored only.

One resource change

A DynamoDB table whose hash_key changed, trimmed to the fields that matter:

{
  "address": "module.data.aws_dynamodb_table.sessions",
  "module_address": "module.data",
  "type": "aws_dynamodb_table",
  "provider_name": "registry.terraform.io/hashicorp/aws",
  "change": {
    "actions": ["delete", "create"],
    "before": { "hash_key": "id", "name": "invented-sessions", "arn": "arn:aws:dynamodb:…" },
    "after": { "hash_key": "session_id", "name": "invented-sessions" },
    "after_unknown": { "arn": true, "id": true },
    "replace_paths": [["hash_key"]]
  },
  "action_reason": "replace_because_cannot_update"
}

The actions

TextactionsWhat happens
+["create"]A new object is created.
-["delete"]The object is destroyed.
~["update"]The object is changed in place.
-/+["delete", "create"]Destroyed, then created again. There's a gap in between.
+/-["create", "delete"]The new one is created before the old one goes.
<=["read"]A data source is read during apply.
 ["no-op"]Nothing changes. A move with no other change is a no-op with a previous_address.
 ["forget"]Taken out of the state by a removed block. The real object is left alone.

What each text symbol and note means is in the plan symbols guide.

Querying it with jq

Each command below was run on the example plan, and its output is shown underneath.

Count the planned actions:

jq -r '.resource_changes[].change.actions | join("/")' plan.json | sort | uniq -c
      3 create
      1 create/delete
      1 delete
      1 delete/create
      6 update

List everything that gets destroyed, including replacements:

jq -r '.resource_changes[] | select(.change.actions | index("delete"))
  | "\(.change.actions | join("/"))  \(.address)"' plan.json
create/delete  aws_instance.web
delete/create  module.data.aws_dynamodb_table.sessions
delete  module.data.aws_sqs_queue.jobs

Show which attribute forced each replacement:

jq -r '.resource_changes[] | select(.change.replace_paths)
  | "\(.address): \(.change.replace_paths | map(map(tostring) | join(".")) | join(", "))"' plan.json
aws_instance.web: ami
module.data.aws_dynamodb_table.sessions: hash_key

Stop a pipeline when the plan destroys anything. With -e, jq exits with status 1 when the answer is false:

jq -e '[.resource_changes[] | select(.change.actions | index("delete"))] | length == 0' plan.json

A gate like this tells you that something is destroyed, not whether it matters. A replaced log group and a replaced database look the same to it.

Sensitive values are in the file

Text output hides sensitive values as (sensitive value). JSON doesn't. In the example plan, an SSM parameter's old and new values are both there in plain text, and before_sensitive and after_sensitive only mark them:

"before": { "value": "invented-ssm-value-one", … },
"after": { "value": "invented-ssm-value-two", … },
"after_sensitive": { "value": true, "tags_all": {} }

Treat plan.json like the plan file itself. Don't attach it to a pull request or keep it as a public build artifact.

Reading it without writing jq

Paste plan.json into Planreader and it ranks destroys and replacements with likely data holders first, gives the reason for each in plain English, draws what refers to what, and writes a pull request summary that leaves out every value. It runs in your browser. Values marked sensitive are dropped while the plan is read, and the page's security policy stops it sending anything anywhere.

Read your plan.json