#39172·terraform

A first-class `terraform drift` command for detecting infrastructure changed outside of Terraform

Author: ajoshi01aiCreated Sep 9, 2026Updated Sep 11, 2026
Labelsenhancementnew

Terraform Version

Terraform v1.17.0-dev
on darwin_arm64

Use Cases

I run Terraform drift checks on a schedule and in CI, across a lot of workspaces. I want them to fire only when something has actually changed outside of Terraform, and to never be able to change anything themselves.

Detection isn't the missing piece, though it's fiddly. terraform plan -refresh-only -detailed-exitcode already returns exit 2 only when there's drift (an unapplied config change doesn't trigger it), and -json emits resource_drift, but you have to know that exact flag combination and parse the JSON to see what actually changed. Two bigger things are the problem.

First, I can't tell a check to ignore drift I already know about, just for that run. There's always some noise, like a tag a scanner rewrites every hour or an attribute the provider keeps normalizing. My only knob today is ignore_changes, and it's the wrong tool for an alarm (more on that below). So I either get paged for noise or mute the whole resource and miss real drift on it.

Second, the check isn't guaranteed read-only. I'm running plan, which takes -out and is one step from something appliable. I'd be a lot more comfortable scheduling that across dozens of workspaces, or handing it to compliance, if it simply couldn't change anything.

Attempted Solutions

For finding drift the refresh-only route is fine.

terraform plan -refresh-only -detailed-exitcode          # 2 on drift, 0 otherwise
terraform plan -refresh-only -json | jq '[.. | select(.type? == "resource_drift")]'

It's the suppression part that breaks down. Take that scanner rewriting tags["LastScanned"] every hour, so every run lights up on it. Right now I have two options.

  • Put ignore_changes = [tags["LastScanned"]] in the config. That quiets the alarm, but now apply won't reconcile that tag either, it needs a review, and it's global, not scoped to this one check.
  • Filter the -json in jq and work out the exit code myself. That's a fragile script I have to babysit per resource type.

Neither lets me ignore that one tag for this check while still failing on any other drift.

I also looked at terraform refresh, but it does the opposite of what I need here. It writes the drifted values into state, which erases the very drift I'm trying to detect.

Proposal

I'd propose a terraform drift command. What makes it more than a wrapper around plan -refresh-only is a -ignore flag that hides expected drift from the report, and only the report.

terraform drift -ignore=aws_s3_bucket.demo drops a whole resource; -ignore='aws_instance.web:tags["LastScanned"]' drops one attribute. The address is just the normal resource instance address you already use in plan output and -target (count/for_each and modules work, managed resources only), and the attribute part is the same syntax as ignore_changes. It only affects what's printed, never state, never infra, never what a later plan or apply does. If it can't apply a rule cleanly, it shows the full drift instead, and anything it hides is still counted and labeled. It only masks what you name, so if the same resource drifted somewhere else you still see it and the check still fails. That's the one thing the refresh-only plus jq route can't do without a config change, and it's the part I've built.

The command itself is small.

terraform drift          # human-readable report
terraform drift -json    # adds a drift_summary with counts

A few specifics.

  • It's strictly read-only. It forces refresh-only and rejects -destroy and -refresh=false (without a refresh it would report "no drift" regardless of the real state), and it can't apply or destroy under any arguments, which a plan-based check can't promise.
  • Exit codes are 0 no drift, 1 error, 2 drift. The 2 lines up with what plan -detailed-exitcode already means. There's no "drift plus pending config" case to worry about, since refresh-only never looks at config changes, and if the refresh itself falls over that's just an error (1).
  • In -json the only new thing is a drift_summary message with changed/deleted/total/ignored. resource_drift is the existing message, untouched.

terraform plan -refresh-only -detailed-exitcode -json already handles detection and gives a clean exit code, so this isn't about finding drift. It's about being able to suppress expected drift per run, and having one read-only command to reach for instead of that flag combination.

On scope, in case it matters for the decision, this is a CLI-layer change with nothing touched in Terraform Core or the backends. I'll share the full diff when there's a PR.

On shape, the real alternative is hanging -ignore (and a drift-scoped exit code) off plan -refresh-only instead of a new command, and honestly I'd be fine with that. The catch is plan still can't promise read-only (it takes -out) and its flag list is already long. Changing what plan -detailed-exitcode returns is off the table, since it'd break everyone relying on today's 0/1/2.

I've got a working implementation with tests and I've run it against a real AWS provider, so I'm glad to share the branch. But per the contributing guide I wanted to talk about direction before dropping code. The real question is do you want a read-only drift command with a suppression flag at all, or would you rather the suppression be a flag on plan -refresh-only? Since -ignore is the whole reason for the command, I'd put it in v1, though I'm happy to stage its surface (resource-level first, attribute-level as a follow-up) if you'd prefer something smaller to start.

References

  • #28803 (closed, addressed by #30486): the drift-report noise problem; #30486 de-noised the plan view, -ignore is the per-run lever for a standalone check.
  • #35226 (open): asks that plan exit 0 when everything matches, showing demand for drift-scoped exit semantics.