Skip to main content
11 min read

Terraform 1.17's -minimal-refresh: 202 API Calls Down to Zero, and the Drift Goes Quiet Too

Terraform 1.17's -minimal-refresh: 202 API Calls Down to Zero, and the Drift Goes Quiet Too

Terraform 1.17.0 shipped on October 21 with a new plan option, -minimal-refresh. It refreshes only the resources that your configuration is about to change, and skips the API reads for everything else. We pointed it at 202 real resources on DigitalOcean. A normal plan made 202 API requests and took 9.5 seconds. With -minimal-refresh it made none and took 4.3 seconds.

Then we deleted one record, changed the TTL of two others behind Terraform's back, and planned again. A normal plan found all three. With -minimal-refresh, Terraform made no API requests and reported no changes.

That trade is the whole story of this flag. It is a real speed-up, and it is also a switch that turns off drift detection for most of your state. This post shows both, with the numbers, so you can decide where it belongs in your pipeline.

TL;DR

  • On 200 DNS records plus a zone and a CNAME (202 resources), a full refresh made 202 GET requests. -minimal-refresh made 0 when nothing changed in config and 1 when one record changed. Median plan time fell from 9.5 s to 4.3 s, about the same as -refresh=false (4.5 s).
  • A second full plan within a minute of the first took 60 s instead of 9.5 s, because two full plans went over DigitalOcean's limit of 250 API requests per minute. The provider's request log showed only 200s; the 429s appeared only in the HTTP client's retry messages (retrying in 51s).
  • After we deleted one record and changed the TTL of two others out of band, a full plan found all three and -minimal-refresh found none. With a config change on r[0], it refreshed only r[0] and still hid the drift on the CNAME that depends on it.
  • A destroy plan with -minimal-refresh skips the refresh of managed resources, so it plans to delete resources that are already gone. The DigitalOcean provider treats the 404 on delete as already deleted, so our destroy still succeeded; a provider that returns an error there would fail.
  • Use it for fast merge request plans on large states, refresh fully before you apply, and keep a scheduled terraform plan -refresh-only (or a normal plan) so drift still has somewhere to show up.

Prerequisites

  • Terraform 1.17.0 or later (terraform version)
  • A DigitalOcean API token with domain read, create and delete scopes, if you want to run the lab
  • A zone name under a domain you control; the lab never delegates it, so no real DNS changes

What the flag does

A refresh reads each resource in the state from the provider's API and records what it finds. What Does Terraform Refresh Really Do? covers the basics. -minimal-refresh changes which resources get that read.

The plan command docs describe it in one line: it "reduces the number of remote API requests by refreshing only resource instances that Terraform plans to change". The implementation is in PR #38953, which spells out the details:

  • For each resource instance, Terraform first plans against the state it already has, without reading the remote object.
  • If that plan has no change, the instance is not refreshed at all.
  • If it has a change, Terraform throws that plan away, refreshes the instance, and plans it again.
  • Each instance decides on its own. A refresh of one resource does not automatically refresh the resources that depend on it.
  • Destroy plans, and deposed or orphaned instances, skip the refresh with this flag.

The docs also say what that costs: the option "causes Terraform to ignore external changes to resources that have no planned changes". The rest of this post measures both sides of that sentence.

The lab

The companion repository creates 202 resources on DigitalOcean: one DNS zone, 200 TXT records and one CNAME. DigitalOcean DNS costs nothing and needs no droplets, and a record refresh is one GET request, so counting requests is simple.

resource "digitalocean_record" "r" {
  count  = var.records
  domain = digitalocean_domain.lab.id
  type   = "TXT"
  name   = format("r%03d", count.index)
  value  = count.index == var.changed ? "v2" : "v1" # -var changed=7 changes one record in config
  ttl    = 1800
}

# Depends on r[0], so a config change to r[0] touches it in the graph.
resource "digitalocean_record" "pointer" {
  domain = digitalocean_domain.lab.id
  type   = "CNAME"
  name   = "pointer"
  value  = "${digitalocean_record.r[0].fqdn}."
  ttl    = 1800
}

Each plan in the clean runs uses TF_LOG=DEBUG, because the DigitalOcean provider prints its API requests only at that level, and plan.sh counts the logged request lines and reads the planned changes from terraform show -json. The plans ran on a Raspberry Pi 3 B+ with 1 GB of RAM, so the absolute times are slower than on a laptop. The request counts do not depend on the machine. They are the requests the provider's request log records; a retry below that logger (Finding 2) is not counted again. We wrote down what we expected before each measured run and committed it to the repository first. SOURCES.md in the repository keeps the exact excerpts from the docs, the PR and the provider source that this post quotes.

Finding 1: 202 requests down to 0, and half the plan time

With nothing changed in the configuration, a full plan read every resource. -minimal-refresh read none. With one record changed in config, it read exactly that one record:

Across three rounds, the median times were:

Two things stand out. First, -minimal-refresh is as fast as -refresh=false here, because with nothing changed it does the same work. Second, a plan without any refresh still took about 4.3 s on this machine. Refreshing all 202 resources added about 5 s on top, roughly 25 ms of wall-clock time per resource with Terraform's default parallelism of 10. We did not split that into network time and Terraform or provider work, so expect both numbers to be different on your hardware.

Finding 2: a second full plan within a minute took six times longer

Our first benchmark looked wrong. Identical full plans took anything from 9 to 62 seconds. The cause was our own script: it ran every plan twice in a row, once to time it and once with logging, so each measurement meant about 404 requests across two consecutive full plans. DigitalOcean allows 5,000 requests per hour and 250 per minute per token. We marked that run as confounded in the repository and measured the effect on purpose instead: three full plans back to back.

The odd part was the log. Every slow plan still showed exactly 202 GET requests and no 429 status line. A rerun of two back-to-back full plans that kept the whole log (burst-log.sh) found the 429s somewhere else:

In that rerun, every HTTP status line in the request log was a 200, but the HTTP client printed ten retry messages. The three the script kept each show a 429 Too Many Requests and a retry scheduled 51 seconds later. The log was silent for 50 seconds, and the second plan took 59.7 s.

The 429s never show up as status lines because of how the provider builds its client (config.go). It creates an HTTP client with retries (4 by default, 1 to 30 s apart; a 51 s wait is longer than that maximum, which suggests it came from DigitalOcean's retry-after header) and then wraps its request logging around that client. The request log sees one request going in and the final 200 coming out. The 429 exists only in the retry library's own message. If a plan suddenly takes a minute longer, search the TF_LOG output for retrying in or status: 429; the HTTP status lines alone hide the retries.

This is where -minimal-refresh earns its keep. A CI system that runs a plan for every push to every open pull request can easily send several full refreshes a minute against the same token. With the flag, an unchanged resource costs no requests at all.

Finding 3: the drift it stops seeing

drift.sh changes three records through the DigitalOcean API, behind Terraform's back:

  • r[42] is deleted
  • r[43] gets a TTL of 60 instead of 1800
  • pointer, the CNAME that depends on r[0], gets a TTL of 60

Then it plans in several ways. Each full-refresh plan waits a minute first, and no 429 was logged in any of them.

Plan API reads Changes it shows
Full refresh 202 pointer update, r[42] create, r[43] update
-minimal-refresh 0 none
Full refresh, r[0] changed in config 202 r[0] update plus the three above
-minimal-refresh, r[0] changed in config 1 r[0] update only
-refresh-only 202 no infrastructure actions; drift reported on pointer, r[42] and r[43] (applying it would update the state)

Source: results/drift-20261029T124036Z.tsv. Full-refresh plans had a 65-second pause before them; the -minimal-refresh plans ran right after.

The second row is the one to remember. One record is gone and two have the wrong TTL, and the plan says there is nothing to do. It will keep saying that on every pull request until someone changes those resources in code or runs a plan that refreshes them.

The fourth row shows the rule from the PR in practice: each resource decides on its own whether to refresh. pointer depends on r[0], and r[0] was refreshed because its config changed, but pointer had no planned change of its own, so its TTL drift stayed hidden. If you expected a change to one resource to pull its dependents into the refresh, it does not do that automatically: a dependent is refreshed only when it has a planned change of its own.

-refresh-only caught all three, which is what it is for. The docs say you cannot combine it with -minimal-refresh, so it cannot be made cheap the same way.

Finding 4: destroy plans skip the refresh completely

With -minimal-refresh, a destroy plan skips the refresh of managed resources, as the PR describes. After r[42] was deleted out of band, a normal destroy plan refreshed, noticed it was gone, and planned 201 deletions. The -minimal-refresh destroy plan made no API requests and planned 202, including the record that no longer existed.

We then destroyed the lab with terraform apply -destroy -minimal-refresh. It exited 0 and reported 202 resources destroyed, one of which no longer existed (results/teardown-20261029T124712Z.txt). That worked only because the DigitalOcean provider's record delete treats a 404 as already gone. A provider that returns an error on a missing object would fail, possibly after some deletions have already completed. For destroy, let Terraform refresh.

What went wrong while we measured

Three mistakes, all caught before writing, all kept in the repository:

  • The first benchmark ran every plan twice back to back, so each full plan meant about 404 requests in quick succession, more than the 250 a minute DigitalOcean allows. Its request counts are right; its timings mix in rate-limit waits. The file says so on its second line, and bench2.sh has the clean timings.
  • Our first drift run asked DigitalOcean to change the CNAME's TTL with a PATCH that sent only the TTL. The API answered 422: Data needs to end with a dot, even though it stores the value without one, so the dependent-resource case did not run. The fixed script sends the value with the trailing dot.
  • plan.sh first read only resource_changes from the plan JSON. A refresh-only plan reports what it found under resource_drift, so it looked like it found nothing. The script now records both.

A fourth check failed in a harmless way: bench2.sh tried to measure the cost of debug logging by unsetting TF_LOG, but the log was still written, so both rows were logged runs. So the cost of debug logging is unmeasured. All the clean timings in this post come from logged runs, so they compare fairly with each other.

When to use it

-minimal-refresh fits the fast plan that runs on every merge request, if your state is large or your provider's rate limit is tight. The resources you changed are still refreshed, so their part of the plan starts from the real remote state. Treat it as early feedback, though: resources without a planned change of their own are planned from state, and a refresh of one resource does not automatically refresh the ones that depend on it, as Finding 3 shows. Run a fully refreshed plan before you apply.

It is the wrong tool anywhere you rely on plans to surface drift. Keep at least one plan that refreshes everything:

# Assumes terraform init, the backend and provider credentials are set up
# in before_script or the image, and a pipeline schedule exists for drift-check.

# Fast plan for every merge request: only changed resources are read.
mr-plan:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
  script:
    - terraform plan -minimal-refresh

# Nightly drift check: reads every resource and fails when anything moved.
drift-check:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "schedule"'
  script:
    - terraform plan -refresh-only -detailed-exitcode # exit code 2 means drift

The docs add two more rules: do not use it, or -refresh=false, when you upgrade provider versions, and you cannot combine it with -refresh-only or -refresh=false. From our results we would add two more: do not use it for destroy plans you want to be accurate, and do not apply a plan made with it without a full refresh first.

What we could not conclude

  • One provider, two resource types, all DNS. A zone and its records are cheap to read. Providers with slow reads (databases, Kubernetes objects, large IAM policies) should save more time per skipped refresh; we did not measure them.
  • One slow machine. The 4.3 s without a refresh was measured on a Raspberry Pi 3 B+. On other hardware both the baseline and the cost of the refresh will be different.
  • Other providers' destroy behaviour. The DigitalOcean provider treats a 404 on delete as already deleted. A provider that returns an error there would fail the destroy instead.
  • Large states. We stopped at 202 resources to stay inside DigitalOcean's 5,000 requests an hour, and our repeated runs still took the token down to 4 remaining requests once.

Summary

-minimal-refresh does what it says. On 202 DigitalOcean resources it cut API requests from 202 to 0 or 1 and plan time from 9.5 s to 4.3 s, about the same as -refresh=false, while still refreshing the resources you change. It also stays clear of the slowdown we saw when a second full plan ran within a minute of the first (60 s), when requests hit DigitalOcean's per-minute limit and were retried 51 s later. The price is drift: with no config change it reported none of the three records we changed behind its back, and a change to one resource did not refresh the resource that depends on it. Use it for fast merge request plans, refresh fully before you apply, keep a scheduled full or refresh-only plan for drift, and do not use it for destroy.

Run the commands from this article in the browser. Nothing to install.

Published: 2026-10-31|Last updated: 2026-10-31T09:00:00Z

Found an issue?

Also worth your time on this topic