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:
terraform plan -jsonprints a live log of events, one JSON object per line. It's meant for tools that show progress, and it leaves out the attribute changes.terraform show -jsonwith no file prints the current state, not a plan.
The parts worth reading
| Field | What it holds |
|---|---|
resource_changes | One entry per resource instance, with its address, the planned actions, and the values before and after. This is the part most tools read. |
resource_drift | Changes made outside Terraform that the refresh found. They aren't actions in this plan, but they often explain it. |
output_changes | Root module outputs, keyed by name, with the same actions, before and after. |
configuration | The configuration as Terraform understood it, including the expressions each attribute uses. That's where references between resources come from. |
relevant_attributes | The 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_values | Full 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, complete | Whether 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"
}
actionsis a list.["delete", "create"]is a replacement with the old table destroyed first, shown as-/+in text.afterleaves out values that aren't known yet.after_unknownmarks them withtrue, and they're the ones text shows as(known after apply).replace_pathslists the attribute paths that forced the replacement, herehash_key. It's there even when the text has no# forces replacementcomment. More on replacement andaction_reason.- Moved resources carry a
previous_address, such asaws_iam_role.appfor a role now ataws_iam_role.ferry_app.
The actions
| Text | actions | What 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.