
If your team runs Terraform in CI, there's a good chance someone has also made a manual change in the cloud console at some point — a security group tweak during an incident, a quick fix that never made it back into code. Terraform calls that drift, and left alone it quietly turns your state file into a lie. This post builds a small Python tool that reads terraform plan -json output and turns it into a report a human can actually act on.
Why not just read terraform plan's text output?
Terraform's human-readable plan output is fine for a one-off review, but it's not built for automation: the formatting changes between versions, color codes leak into logs, and there's no stable way to ask "how many resources drifted, and which ones are dangerous?" Terraform has shipped a documented, versioned JSON output format specifically so tools can parse it reliably. That's the contract we'll build against.
Generate it like this:
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > plan.json
Two commands, not one: terraform plan -json as a standalone flag doesn't exist on older Terraform versions reliably for this purpose — the supported path is to save a plan file and then ask terraform show to render it as JSON. That JSON is what the rest of this post works with.
Understanding the shape of the JSON
The structure you care about most is resource_changes, a flat list where each entry describes one resource and what Terraform plans to do to it. The field that matters is change.actions, a list of strings like ["no-op"], ["update"], ["create"], ["delete"], or ["delete", "create"] for a replace.
{
"resource_changes": [
{
"address": "aws_security_group.web",
"mode": "managed",
"type": "aws_security_group",
"name": "web",
"provider_name": "registry.terraform.io/hashicorp/aws",
"change": {
"actions": ["update"],
"before": { "ingress": [{"from_port": 22, "cidr_blocks": ["10.0.0.0/8"]}] },
"after": { "ingress": [{"from_port": 22, "cidr_blocks": ["0.0.0.0/0"]}] }
}
}
]
}
Drift, specifically, is any resource where Terraform wants to change something that a human didn't request through a code change — in practice, anything that isn't no-op when you run a plan against unmodified code is either drift, or a change you haven't applied yet. The two look identical in this JSON; a drift detector's job is to make the distinction clear to the reader by flagging what changed and how severe it looks, not to magically know intent.
Parsing it with Python
Start simple: load the file and pull out every resource whose action isn't no-op.
import json
import sys
from dataclasses import dataclass, field
@dataclass
class DriftedResource:
address: str
resource_type: str
actions: list
before: dict
after: dict
changed_fields: list = field(default_factory=list)
def load_plan(path: str) -> dict:
with open(path, "r") as f:
return json.load(f)
def find_drift(plan: dict) -> list[DriftedResource]:
drifted = []
for rc in plan.get("resource_changes", []):
actions = rc["change"]["actions"]
if actions == ["no-op"] or actions == []:
continue
before = rc["change"].get("before") or {}
after = rc["change"].get("after") or {}
drifted.append(
DriftedResource(
address=rc["address"],
resource_type=rc["type"],
actions=actions,
before=before,
after=after,
changed_fields=diff_fields(before, after),
)
)
return drifted
def diff_fields(before: dict, after: dict) -> list[str]:
changed = []
keys = set(before.keys()) | set(after.keys())
for key in sorted(keys):
if before.get(key) != after.get(key):
changed.append(key)
return changed
diff_fields is deliberately shallow — it flags top-level keys that changed, not a deep recursive diff. For a security group's ingress list, for example, it'll tell you ingress changed, which is usually enough to prompt a human to go look, without drowning the report in nested diff noise.
Scoring severity
Not all drift matters the same. A tag that changed is noise; a security group that now allows 0.0.0.0/0 on port 22 is an incident. A simple rule-based severity scorer gets you most of the value without needing a policy engine:
HIGH_RISK_TYPES = {"aws_security_group", "aws_security_group_rule", "aws_iam_policy", "aws_iam_role", "aws_s3_bucket_policy"}
HIGH_RISK_FIELDS = {"ingress", "egress", "policy", "cidr_blocks", "public_access_block"}
def score_severity(resource: DriftedResource) -> str:
if "delete" in resource.actions and "create" not in resource.actions:
return "high" # something is about to be destroyed outside of code
if resource.resource_type in HIGH_RISK_TYPES:
if any(f in HIGH_RISK_FIELDS for f in resource.changed_fields):
return "high"
return "medium"
if resource.changed_fields:
return "low"
return "info"
Turning it into a readable report
Now stitch it together into something a human reads in Slack or a PR comment, not raw JSON:
def build_report(drifted: list[DriftedResource]) -> str:
if not drifted:
return "No drift detected. Infrastructure matches Terraform state.\n"
lines = [f"Drift report: {len(drifted)} resource(s) out of sync\n"]
by_severity = {"high": [], "medium": [], "low": [], "info": []}
for r in drifted:
by_severity[score_severity(r)].append(r)
for severity in ("high", "medium", "low", "info"):
resources = by_severity[severity]
if not resources:
continue
lines.append(f"\n[{severity.upper()}] {len(resources)} resource(s)")
for r in resources:
action_str = "/".join(r.actions)
fields_str = ", ".join(r.changed_fields) or "(no field-level detail)"
lines.append(f" - {r.address} ({action_str}): changed fields -> {fields_str}")
return "\n".join(lines)
if __name__ == "__main__":
plan = load_plan(sys.argv[1] if len(sys.argv) > 1 else "plan.json")
drifted = find_drift(plan)
print(build_report(drifted))
if any(score_severity(r) == "high" for r in drifted):
sys.exit(1) # non-zero exit fails the CI job
Run it against a real plan:
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > plan.json
python3 drift_report.py plan.json
Wiring it into CI on a schedule
Drift detection is only useful if it runs without anyone remembering to kick it off. A scheduled CI job that runs terraform plan against the real state — without applying anything — and feeds the output through this script is the usual pattern:
name: drift-detection
on:
schedule:
- cron: "0 7 * * *"
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init -input=false
- run: terraform plan -out=tfplan.binary -input=false
- run: terraform show -json tfplan.binary > plan.json
- run: python3 drift_report.py plan.json
Common pitfalls
- Treating every non-
no-opas drift. A plan run right after a merge that hasn't been applied yet will show the same "pending change" shape as real drift. Run drift detection against an environment you know is already applied, or compare against the last successful apply's plan hash. - Ignoring sensitive values. Terraform marks some fields as sensitive in the JSON (
before_sensitive/after_sensitiveflags); don't print rawbefore/afterblobs into a shared Slack channel without checking for those flags first. - Not handling
nullproviders or data sources.resource_changesincludes data sources too, where almost everything looks like a no-op "read" — filter onmode == "managed"if you only care about resources Terraform actually owns.
Troubleshooting
If find_drift returns nothing but you know something changed outside Terraform, check that your state is actually refreshed — a stale state file means Terraform's plan is comparing against old data, not the live cloud resource. Run terraform plan -refresh=true explicitly (it's the default, but some wrapper scripts turn it off for speed) before generating the JSON.
If the script throws a KeyError on change, double check you ran terraform show -json against a saved plan file, not against the state file directly — terraform show -json terraform.tfstate has a different top-level shape than a plan.
Where to go next
A natural next step is posting this report straight into a PR comment or a Slack channel via webhook, and tracking drift history over time in a small database so you can see whether the same resource keeps drifting — which usually means someone has a standing manual workaround that needs to be codified instead of fought.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


