TerraformII · Terraform ArchitectureProduction Terraform
Refresh and Plan: How They Interact
What you'll learn
- Explain what refresh does and when it runs by default in Terraform 1.9.x
- Distinguish refresh from re-plan
- Apply -refresh-only to update state after out-of-band changes
- Recognise how -target and -replace interact with refresh
- Identify the production failure modes of refresh behaviour
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
Refresh is the operation that brings Terraform’s state into
agreement with the real world. It is read-only; it does not
change infrastructure and does not change the configuration. But
it does change the state file, and that subtle property is the
source of most “the plan is lying to me” tickets in production.
This lesson covers what refresh does, when it runs in 1.9.x, and
how the -refresh-only mode and -replace flag interact with the
plan.
What refresh does
For each resource tracked in state, refresh asks the provider to read the current real-world attributes and write them into state. Refresh does not propose changes to the configuration; it proposes changes to the state file.
State (last refresh) Real world (now)
| |
| provider ReadResource |
| <----------------------- |
| |
v |
State (refreshed) <-------------+
If state and reality agree, refresh is a no-op. If state and reality disagree (drift), refresh updates state with reality’s view. The next plan sees the updated state and computes the diff against the new state, not the old.
When refresh runs in Terraform 1.9.x
The history of refresh behaviour is worth knowing because old blog posts and Stack Overflow answers describe the 0.x behaviour:
- Terraform 0.x — 0.15.3: refresh was implicit during plan and apply. There was no way to disable it.
- Terraform 0.15.4 (2021): refresh during plan became
optional via
-refresh=false. Refresh during apply was also optional. - Terraform 1.0 — 1.2: refresh defaulted to enabled during
plan and apply. The
terraform refreshstandalone command was deprecated. - Terraform 1.3: the
-refresh-onlymode was added toterraform planandterraform apply. The standaloneterraform refreshcommand was removed. - Terraform 1.9.x (this course): refresh defaults to enabled during plan. Refresh during apply was changed in 1.3 to default to disabled during apply unless the state has changed since the plan was created.
The 1.3 change is subtle but operationally significant. Before
1.3, every apply refreshed state by default. In 1.3+, apply
refreshes state only if the state has changed since the plan
was created (which catches the common case of a separate
terraform refresh between plan and apply) or if the plan was
not saved.
Refresh vs re-plan
Two related but distinct operations:
- Refresh updates the state file with the current real-world attributes. It does not look at the configuration. It does not compute a diff.
- Re-plan re-reads the configuration, asks the provider for the diff for each resource, and produces a new plan.
# Refresh only: state updated, no plan produced
terraform apply -refresh-only
# Re-plan: state refreshed + new plan computed
terraform plan
In Terraform 1.9.x, terraform plan does both: it refreshes
state, then computes the plan against the refreshed state. The
two steps are interleaved per resource — for each resource,
refresh first, then plan.
For each resource in graph order:
refresh -> update internal state
plan -> compute diff for this resource
This is why a single terraform plan is enough to detect drift
and propose the right corrective action.
-refresh-only: refresh without plan
terraform plan -refresh-only
terraform apply -refresh-only
refresh-only mode tells Terraform to perform only the refresh
step. The output is a plan that proposes only state changes,
no infrastructure changes:
aws_instance.web: Refreshing state... [id=i-0abc123]
Terraform will perform the following actions:
# aws_instance.web will be updated in-place
~ resource "aws_instance" "web" {
id = "i-0abc123"
~ tags = {
+ "Environment" = "production"
}
# (4 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.
Note the summary line: 0 to add, 0 to change, 0 to destroy. The
plan output shows in-place updates to state, not to
infrastructure. The apply, if approved, writes state but does
not call any mutating provider RPC.
The production use case: an operator made an out-of-band change
to a resource (a tag in the cloud console, a parameter on a
database). Terraform’s state does not know about the change. The
next plan would propose a change to undo the operator’s action,
because Terraform sees the drift as a difference between
configuration and state. terraform apply -refresh-only updates
state to match reality, so the next plan shows no change.
-target interaction with refresh
terraform plan -target=aws_instance.web
-target filters which resources the plan considers. The filter
also filters which resources are refreshed: only the targeted
resources and their dependencies are refreshed; unrelated
resources are not.
Two consequences:
-
Drift in non-targeted resources is invisible. If a non-targeted resource has drifted in the real world, the plan does not see it (because it was not refreshed) and does not propose to update its state. The drift remains invisible.
-
Targeting a downstream resource still refreshes its dependencies. Targeting
aws_instance.webtriggers refresh ofaws_subnet.publicbecause the instance references it.
A production rule: do not use -target to investigate drift. A
drifted resource not covered by the target will be missed, and
the operator will believe the drift does not exist.
-replace: forced destroy-create
terraform plan -replace=aws_instance.web
-replace forces Terraform to destroy the targeted resource and
create a new one, regardless of whether the configuration has
changed. The refresh still runs by default for the targeted
resource.
The use cases:
- A resource has accumulated state in the real world that cannot be updated in place (for example, an EC2 instance type that requires a stop).
- A provider bug produced a state that cannot be reconciled with configuration by normal update operations.
- An operator wants to verify a resource’s creation path by destroying and recreating it.
-replace does not bypass the plan. The plan still shows what
will happen; the operator still approves.
When refresh hides real changes
Refresh has one production surprise: it can absorb out-of-band changes into state, making them invisible to subsequent plans.
Scenario:
- Configuration says
tags = { Name = "web-01" }. - State says
tags = { Name = "web-01" }(from last refresh). - An operator runs
aws ec2 create-tags --resources i-0abc --tags Key=Environment,Value=production. - A
terraform planruns. Refresh sees the new tag. State updates totags = { Name = "web-01", Environment = "production" }. - The plan computes the diff: configuration says
Name = "web-01", state saysName = "web-01", Environment = "production". Terraform sees no change because the configuration’sNamematches. - The next plan shows no change. The operator’s tag is now in state, invisible to the next person.
The hidden change is not gone — it is in state, and the next
apply that touches the resource will preserve it. But the
configuration does not mention it, and a terraform state show
will display it. A new engineer reading the configuration has no
way to know the resource has the Environment tag.
A production rule: refresh absorbs out-of-band changes into
state. If the out-of-band change is intended, encode it in
configuration. If it is unintended, use -target or
-replace to address it.
Production failure modes
| # | Failure mode | Observable symptom | Recovery |
|---|---|---|---|
| 1 | Refresh fails for one resource (provider timeout) | Error: Failed to read resource state for that one resource; the rest of the plan continues | Investigate the resource; re-run the plan; the failure is resource-specific |
| 2 | -refresh=false skipped on a stale-state plan | Plan shows no changes but real world has drifted | Re-plan without -refresh=false; investigate the actual state |
| 3 | -refresh-only applied unintentionally | State updated but operator expected the apply to do nothing | Restore state from backup if the change is not desired |
| 4 | Provider’s read function does not return all attributes | Drift in attributes not in the schema is invisible | Check the provider’s source; some attributes are deliberately not surfaced |
| 5 | Refresh takes very long (provider rate-limited, large state) | terraform plan hangs on refresh | Use -refresh=false for fast plan; run refresh separately; increase provider rate limits |
| 6 | Drift in a resource not covered by -target | Drift invisible to the targeted plan | Run terraform plan without -target to refresh all resources; check terraform state show for the drifted resource |
Security implications
- Refresh reads from cloud APIs using the provider’s credentials. It uses the same credentials as apply, but it does not write anything. The credentials’ scope should still follow least privilege.
- A refresh that runs unattended may expose drift to the
wrong person. CI/CD plans with
-targetmay refresh resources and write the updated state to the backend. The state file may now contain attributes the operator did not intend to reveal. -refresh-onlydoes not change the access pattern but does change the audit trail. The state file is rewritten, the serial is bumped, and the lineage may be preserved. The change is logged in the backend’s history.
Performance implications
- Refresh is O(N) provider read calls. For a 5,000-resource
state, refresh is 5,000 read API calls. With
-parallelism=10, about 500 seconds of wall-clock time at 100 ms per call. - Refresh during plan and refresh during apply are not the same. Plan refresh runs against every resource. Apply refresh (1.3+) runs only against resources where state has changed since the plan. A plan from 09:00 saved and applied at 09:30 may trigger a refresh for the apply if any resource was modified externally.
-refresh=falsemakes plan much faster. For a large estate, disabling refresh cuts plan time by half or more. The trade-off is plan accuracy.
Production guidance
- Always enable refresh during plan.
-refresh=falseis a development convenience, not a production setting. - Use
-refresh-onlyafter out-of-band changes. This is the intended use case. - Do not use
-targetto investigate drift. A non-targeted resource’s drift will be missed. - Be aware that refresh absorbs out-of-band changes. Encode intentional changes in configuration.
- Audit
terraform planoutput before applying. The plan shows refresh-driven state changes; do not let them pass silently.
Verification
- What does refresh do, and what does it not do?
- When does refresh run by default in Terraform 1.9.x, and when does it not?
- What is the difference between
-refresh-onlyand a normal plan? - How does
-targetinteract with refresh, and what is the production risk? - How can refresh hide an out-of-band change from the next plan?
Knowledge check · 7 questions
Q1. What does terraform refresh do?
Q2. In Terraform 1.9.x, when does refresh run by default?
Q3. terraform apply -refresh-only updates the state file without calling any mutating provider API.
Q4. What is the production risk of using -target to investigate drift?
Q5. Which of the following are part of the refresh-and-plan sequence? (Select all that apply.)
Q6. How does refresh hide out-of-band changes?
Q7. An operator manually adds a tag to an EC2 instance via the AWS console. A subsequent terraform plan shows no changes. What should the team do?
Passing score: 75%. Answers are checked in this browser.