ctx: prod
back to posts
$kubectl describepost/terraform-minimal-refresh

Terraform -minimal-refresh: a faster plan that stops looking at drift

Terraform 1.17.0-rc1 shipped on October 7 with a planning flag people have asked for since mid-2024: -minimal-refresh. rc2 followed today, October 14, and GA has not landed yet, so everything below is from the release candidate.

The flag makes plan cheaper on large states by skipping the refresh for resources your change does not touch. That is exactly what it promises, and it is also exactly why it should not go anywhere near the job that is supposed to catch drift. I ran it to see what that looks like in practice, and the output is more misleading than I expected.

▸The 60-second version, flip through the deck9 slides · swipe →
The premise

Fast plan, blind plan

-minimal-refresh in Terraform 1.17 skips the refresh for anything your change doesn't touch.

The mechanism

Plan first, refresh only on a diff

Each resource is planned against stored state. No-op means no refresh.

The run

Same drift, two answers

With the flag: No changes, exit 0. Without it: 1 to add, exit 2.

the catch

Untouched means unread

Drifted resources outside the change, and dependents of changed ones, are not refreshed.

The fix

Two jobs, not one flag

Minimal refresh on PR plans. A full refresh on a schedule, alerting on exit code 2.

Guardrails

Terraform won't mix it

Errors with -refresh=false and with -refresh-only.

The numbers

207 · 2 years · 0 vs 2

Reactions on #35290, time from issue to rc, and the exit codes for one drift.

takeaway

Code diff vs cloud diff

A fast plan diffs your code. Only a full plan diffs your cloud.

Why plan was slow in the first place

A normal terraform plan starts with a refresh: for every managed resource, the provider reads the real object back from the API and updates the in-memory state. On a state with a few hundred resources that is a few hundred read calls before Terraform even looks at your diff.

Issue #35290, opened on June 4, 2024 under the title "terraform plan -light", lists the three workarounds people were using and why each one hurts:

the workarounds, before 1.17
Raise -parallelismMore concurrent reads, until the provider's API rate limit pushes back.throttled
-refresh=falseNo reads at all. Plan runs against whatever state last recorded, for every resource.stale
Split the stateSmaller roots, faster plans, and a dependency graph you now maintain across states.overhead

The issue collected 207 reactions. It was closed on August 19, 2026 by PR #38953, which did not implement the original "-light" proposal but something more careful.

How -minimal-refresh decides

The PR description is short and worth reading in full. The mechanism, per resource instance:

per resource, during plan
planagainst the stored state, no refresh
→
no-op?keep it, skip the refresh entirely
→
a change?discard, refresh, plan again

Using a real plan call as the signal, rather than a text diff of the config, is the smart part: the provider decides what counts as a meaningful change, so whitespace or an equivalent value does not trigger a refresh.

The help text in rc2 is honest about the trade:

  -minimal-refresh    Skips checking for external changes to objects managed by
                      this configuration when an initial plan indicates that the
                      object has not changed since the most recent Terraform apply.
                      This can potentially make planning faster, but at the expense
                      of possibly planning against a stale record of the remote
                      system state.

The run

I wanted to see the failure mode for myself. Two local_file resources, applied with Terraform v1.17.0-rc2 on darwin_arm64:

resource "local_file" "app_config" {
  filename = "${path.module}/out/app.conf"
  content  = "replicas = 2\n"
}

resource "local_file" "feature_flags" {
  filename = "${path.module}/out/flags.json"
  content  = jsonencode({ new_checkout = false })
}

Then the drift: someone flips a flag by hand, outside Terraform.

echo '{"new_checkout":true}' > out/flags.json

With the new flag:

$ terraform plan -minimal-refresh -detailed-exitcode; echo $?

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
0

Without it:

$ terraform plan -detailed-exitcode; echo $?
local_file.feature_flags: Refreshing state... [id=0932faec15d7d4ea4fc3aa3808d19b09bfedac33]
local_file.app_config: Refreshing state... [id=9c9784843f694b0d666d6e2413b3edc46e87e47b]
...
  # local_file.feature_flags will be created
...
Plan: 1 to add, 0 to change, 0 to destroy.
2

Zero Refreshing state... lines in the first run, two in the second. Exit code 0 against exit code 2. And the first run's closing message says Terraform compared your real infrastructure against your configuration, which it did not do. The wording predates the flag, and it now describes something that did not happen.

A fast plan diffs your code. Only a full plan diffs your cloud.

What gets refreshed and what doesn't

Then I made a "PR change": replicas = 2 became replicas = 3 in app_config, with the hand-edited flag file still in place.

$ terraform plan -minimal-refresh
local_file.app_config: Refreshing state... [id=9c9784843f694b0d666d6e2413b3edc46e87e47b]
  # local_file.app_config must be replaced
Plan: 1 to add, 0 to change, 1 to destroy.

$ terraform plan
local_file.feature_flags: Refreshing state... [id=0932faec15d7d4ea4fc3aa3808d19b09bfedac33]
local_file.app_config: Refreshing state... [id=9c9784843f694b0d666d6e2413b3edc46e87e47b]
  # local_file.app_config must be replaced
  # local_file.feature_flags will be created
Plan: 2 to add, 0 to change, 1 to destroy.

The changed resource was refreshed and planned correctly. The drifted one sitting next to it was not mentioned at all. Combined with the PR notes, the full picture is:

ResourceRefreshed under -minimal-refresh?Source
Changed by your configyes, after an initial planPR #38953, my run
Unchanged but driftednomy run
Depends on a changed resourceno, each instance decides in isolationPR #38953
Schema upgraded by a new provideralwaysPR #38953
Destroy plans, deposed and orphanedneverPR #38953

The maintainer put the consequence plainly in the PR thread, answering whether the flag affects drift detection: "if you never update your state (for example, if you always ran with this flag), then you'll never detect any drift until you change your configuration."

Split the PR plan from the drift check

The flag is fine. The risk is the pipeline that has exactly one plan job and treats it as both the review gate and the drift detector. A lot of setups do, because until now every plan was a full refresh and you got drift detection for free.

So split it. The PR plan exists to show reviewers what this change will do, and that is the job -minimal-refresh was built for:

# pull request: what does this change do?
terraform plan -minimal-refresh -out=tfplan

The drift check exists to show what the cloud did without you, and it needs every resource read back:

# schedule: did anything change outside Terraform?
terraform plan -detailed-exitcode -lock=false
rc=$?
[ "$rc" -eq 2 ] && echo "drift detected" && exit 1
exit "$rc"

Terraform also refuses the combinations that would make this ambiguous. Both errors are from my rc2 run:

$ terraform plan -minimal-refresh -refresh=false
Error: Incompatible refresh options

The -minimal-refresh and -refresh=false options are mutually-exclusive,
because -minimal-refresh only affects whether Terraform refreshes.

$ terraform plan -minimal-refresh -refresh-only
Error: Incompatible plan mode options

The -minimal-refresh option can only be used in normal or destroy planning
modes, but the current mode is RefreshOnlyMode.

If you are on HCP Terraform or Terraform Enterprise, check before you put it in TF_CLI_ARGS_plan. A user asked exactly that in the PR thread, because their workspace relies on background drift detection; the answer was that platform support is still in progress on a separate team.

It's the same direction as --filter

The line I wanted people to leave our June Navitalks demo with was "A year ago this repo needed seven --include flags and a wrapper script for a PR-scoped plan. Now it's one filter." --filter narrows which units get planned. -minimal-refresh narrows which resources inside a unit get read. Both make PR plans cheaper by looking at less, and both are correct for that job. Neither is a drift detector, and the more you narrow the PR plan, the more you need one somewhere else.

Also in 1.17

Two other items from the same release notes, briefly: variables and locals are now allowed in required_providers (PR #39153, const variables only, nothing that depends on a plan result), and Terraform Policy is GA, so -policies on plan, apply and query no longer needs an experimental build.

What to actually do

  • Find out whether your drift detection is a dedicated job or just "the PR plan would show it"
  • If it's the latter, add a scheduled full-refresh plan with -detailed-exitcode before adopting the flag
  • Then add -minimal-refresh to PR plans only, after 1.17 is GA
  • Keep it out of TF_CLI_ARGS_plan and any global env that also feeds the drift job
  • Time it on your real stack before quoting a speed-up; nobody has published one

I'm not going to quote a speed-up number. HashiCorp hasn't published one, and two local files plan in a tenth of a second either way. When I time it on a real stack I'll add the numbers here.


Where does your drift check actually run today? If the honest answer is "the PR plan", this flag quietly removes it.