Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Terraform By James Joyner IV · · 11 min read

Parsing Terraform Plan JSON for AI-Assisted Review

Export terraform plan JSON, then use jq plus AI to summarize and risk-score changes in CI, with humans on every apply and never handing over state or creds.

  • #terraform
  • #plan
  • #jq
  • #ci
  • #ai

The first time an AI reviewer “helped” me with a Terraform change, I almost handed it the keys to the kingdom. I’d pasted a raw plan into a chat window — the one with the new RDS password printed in plaintext — and only caught it mid-keystroke. That was the moment I decided the model would never again see anything but a sanitized, machine-readable artifact. No state. No credentials. No cloud access. Just the plan, in JSON, with the secrets stripped out.

It turns out Terraform already gives you exactly that artifact for free. You just have to ask for it the right way.

Why JSON Instead of the Pretty Plan

The human-readable output of terraform plan is great for eyeballing, but it’s a nightmare to parse. The +, -, and ~ symbols are visual sugar, not a contract. Diffing it with regex is how you end up missing a -/+ replacement that nukes your database.

Terraform has a stable, documented JSON representation of the plan. That’s what we feed to tooling — and to the AI. The format is versioned (format_version), so your parsing logic won’t silently break on a minor upgrade. Machine-readable in, deterministic checks out.

Producing the Plan JSON

This is a two-step dance. You can’t get JSON straight out of plan in a way that’s safe to re-apply; you save a binary plan file first, then render it.

# 1. Generate a saved plan file (binary, do not commit this)
terraform plan -out=tfplan

# 2. Render that exact plan as JSON
terraform show -json tfplan > plan.json

The key detail: terraform show -json runs against the saved plan file, not the live infrastructure. It needs no fresh API calls and reflects precisely the changes that an apply of tfplan would make. That separation is the whole foundation of the guardrail — the JSON describes intent, and we gate on intent before anyone applies it.

Pro Tip: Add tfplan and plan.json to your .gitignore. The binary plan can contain sensitive values, and even the JSON may carry them under before/after until you redact. Treat both as secrets in transit.

The resource_changes Structure

Everything we care about lives in the top-level resource_changes array. Each element describes one resource and its proposed change. The shape, trimmed, looks like this:

{
  "resource_changes": [
    {
      "address": "aws_db_instance.primary",
      "type": "aws_db_instance",
      "name": "primary",
      "provider_name": "registry.terraform.io/hashicorp/aws",
      "change": {
        "actions": ["delete", "create"],
        "before": { "engine": "postgres" },
        "after": { "engine": "postgres" },
        "after_unknown": {},
        "before_sensitive": { "password": true },
        "after_sensitive": { "password": true }
      }
    }
  ]
}

The field that drives all our logic is .change.actions. It’s an array, and the combination matters:

  • ["no-op"] — nothing changes.
  • ["create"] — new resource.
  • ["update"] — in-place modification.
  • ["delete"] — destroy.
  • ["delete", "create"] — replace, destroy-then-create order.
  • ["create", "delete"] — replace, create-then-destroy (when create_before_destroy is set).

That ["delete", "create"] pair is the one that has ruined weekends. A casual reading of a plan calls it a “change,” but it’s a destroy. Parsing JSON makes the distinction unambiguous.

jq Queries That Actually Tell You Something

Now the fun part. With plan.json in hand, jq turns the plan into facts.

Count what’s happening, bucketed by action:

jq -r '
  [.resource_changes[].change.actions | join(",")]
  | group_by(.) | map({action: .[0], count: length})
' plan.json

List every resource being deleted (a pure destroy):

jq -r '
  .resource_changes[]
  | select(.change.actions == ["delete"])
  | .address
' plan.json

List every replacement — both orderings — because these are deletes wearing a costume:

jq -r '
  .resource_changes[]
  | select(
      (.change.actions == ["delete","create"]) or
      (.change.actions == ["create","delete"])
    )
  | .address
' plan.json

Build a compact, AI-friendly summary object — addresses and actions only, no values:

jq '{
  creates:  [.resource_changes[] | select(.change.actions == ["create"]) | .address],
  updates:  [.resource_changes[] | select(.change.actions == ["update"]) | .address],
  deletes:  [.resource_changes[] | select(.change.actions == ["delete"]) | .address],
  replaces: [.resource_changes[]
    | select(.change.actions == ["delete","create"] or .change.actions == ["create","delete"])
    | .address]
}' plan.json > plan-summary.json

That plan-summary.json is the only thing the AI ever sees. It has no before, no after, no provider config — just the topology of the change.

Redacting Sensitive Values

Even when you do want to send attribute-level detail, Terraform tells you exactly what to hide. The change object carries before_sensitive and after_sensitive maps (and there’s a top-level .sensitive_values view per resource too) where each sensitive attribute is marked true. Anything Terraform flagged sensitive — passwords, keys, tokens — you strip before the JSON leaves your CI runner.

A pragmatic approach: only ever extract the keys of after, never the values, for sensitive attributes:

jq '
  .resource_changes[]
  | {
      address,
      actions: .change.actions,
      sensitive_keys: (.change.after_sensitive
        | paths(scalars) | join("."))
    }
' plan.json

The discipline is simple: if Terraform marked it sensitive, the model gets the name of the field at most, never its content. Better still, default to the topology-only summary above and let the AI ask for more only when a human approves.

Handing the Redacted Summary to AI

Now the AI does what it’s good at: reading a structured artifact quickly and flagging what a tired human might skim past. I treat it as a fast junior engineer — one that reads only the redacted plan JSON, has zero cloud access, and whose output is a recommendation, not an action.

PLAN_SUMMARY=$(cat plan-summary.json)

cat <<EOF > review-prompt.txt
You are reviewing a Terraform plan summary. You have NO access to state,
cloud credentials, or the ability to apply anything. Input is redacted JSON
containing only resource addresses and actions.

Tasks:
1. Summarize the change in two sentences.
2. Assign a risk score 1-5 (5 = potential data loss / outage).
3. Call out every delete and replace explicitly, with the blast radius.
4. Recommend block or proceed-with-human-review. Never recommend auto-apply.

Plan summary:
$PLAN_SUMMARY
EOF

Pipe that into whichever assistant you’ve wired up — Claude, ChatGPT, or a local model like Gemma if you’d rather the JSON never leave your network. The model returns prose and a number. That number is advisory. The actual gate is deterministic.

Pro Tip: Pin the risk-scoring instructions in a reusable prompt rather than retyping them each run. I keep mine in the prompt workspace and pull curated review prompts from the prompt packs so every pipeline scores the same way.

The CI Gate That Fails on Unexpected Deletes

The AI summary goes in the PR comment for humans. The build pass/fail decision belongs to jq and an exit code — no model in the critical path.

#!/usr/bin/env bash
set -euo pipefail

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

# Count destructive actions: pure deletes plus replacements.
DESTROY_COUNT=$(jq '
  [.resource_changes[]
    | select(
        (.change.actions == ["delete"]) or
        (.change.actions == ["delete","create"]) or
        (.change.actions == ["create","delete"])
      )
  ] | length
' plan.json)

if [ "$DESTROY_COUNT" -gt 0 ]; then
  echo "::error::Plan contains $DESTROY_COUNT destructive change(s):"
  jq -r '
    .resource_changes[]
    | select(
        (.change.actions == ["delete"]) or
        (.change.actions == ["delete","create"]) or
        (.change.actions == ["create","delete"])
      )
    | "  - \(.address): \(.change.actions | join("→"))"
  ' plan.json
  echo "Destructive changes require explicit human approval. Failing build."
  exit 1
fi

echo "No destructive changes detected."

This fails the pipeline the instant a delete or replace shows up that nobody approved. To allow an intentional destroy, a human adds the resource address to an allowlist file checked into the PR — a deliberate, reviewable act, not a default. The gate is dumb on purpose: deterministic, auditable, and impossible for a hallucination to talk its way around.

The division of labor is the whole point. The AI explains why a change might be risky and does it in seconds. The jq gate decides whether the build proceeds. And a human reads every plan before typing terraform apply — which still happens on a trusted runner that holds the credentials the model never touches.

If you want to formalize this further, wire the summary into your existing review and on-call tooling: I route plan summaries through a code review dashboard and let the monitoring alerts view catch the aftermath if a risky apply slips through anyway.

Conclusion

terraform show -json plus a few jq filters gives you a redacted, structured artifact you can trust — one that describes intent precisely enough to detect every delete and replacement. The AI reads that artifact like a fast, tireless junior engineer and writes up the risk. The deterministic gate makes the call. The human approves the apply. Nobody ever hands the model your state file or your cloud keys. That’s the entire trick, and it scales from one repo to a hundred.

Browse more Terraform guides or grab ready-made review prompts to drop into your pipeline today.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.