TerraformV · The Terraform WorkflowProduction Terraform
terraform plan in Depth
What you'll learn
- Read every symbol in the plan output and identify the kind of change it represents
- Use -out, -json, -target, -replace, -refresh-only, and -detailed-exitcode in the right context
- Configure a CI plan job that produces a saved plan file for downstream apply
- Explain why apply must consume the same plan that was reviewed, and what -out makes possible
- Diagnose six common plan failure modes including drift, refresh failures, and dependency-graph breakage from -target
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
terraform plan is the command that turns configuration into a contract. The output is a precise description of what terraform apply will do if it runs immediately, in this order, against the current state. The plan is the unit of human review. It is also the unit of CI review. Treating it as anything less than a contract is how production changes escape review.
What plan does, step by step
A plain terraform plan runs four phases in sequence:
- Refresh. Terraform calls the provider for every existing resource and updates the state to reflect the real world. Drift is detected here.
- Graph construction. Terraform builds the dependency graph from configuration plus state.
- Diff. Terraform walks the graph and computes the set of changes: create, update, replace, destroy, no-op.
- Print. Terraform writes the human-readable plan to stdout. The actual cloud is not touched.
The default refresh is what makes a no-op plan a meaningful signal. A plan that shows zero changes means the configuration, the state, and the real world all agree. A plan that shows zero changes with -refresh=false only means the configuration and the state agree — and that is a much weaker signal.
Reading the plan output
Every line in a plan has a prefix that tells you exactly what will happen:
Terraform will perform the following actions:
# aws_instance.web must be replaced
-/+ resource "aws_instance" "web" {
~ id = "i-0abc123def456789" -> (known after apply)
~ instance_type = "t3.small" -> "t3.medium"
~ ami = "ami-0a1b2c3d4e5f" -> (known after apply) # forces replacement
~ tags = {
"Name" = "web-1"
"Env" = "prod"
}
}
# aws_security_group_rule.https will be created
+ resource "aws_security_group_rule" "https" {
+ id = (known after apply)
+ type = "ingress"
+ from_port = 443
+ to_port = 443
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"]
}
# aws_iam_role.legacy will be destroyed
- resource "aws_iam_role" "legacy" {
~ arn = "arn:aws:iam::123456789012:role/legacy" -> null
}
Plan: 2 to add, 1 to change, 1 to destroy.
The prefix is the entire contract:
| Symbol | Meaning |
|---|---|
+ (green) | Will be created |
- (red) | Will be destroyed |
~ (yellow) | Will be updated in place |
-/+ | Destroy and then create replacement |
+/- | Create replacement and then destroy (create_before_destroy = true) |
<= | Read (data resources): a data source will be read during the apply |
A -/+ line is the one operators learn to fear. Replace means a stateful resource will be destroyed and recreated. For an RDS database, that is data loss unless you have a snapshot, a read replica promotion, or an explicit decision to accept loss.
The flags that matter
The default plan is enough for an interactive operator. For production workflows, every one of these flags earns its place.
-out=path
Saves the plan to a binary file. The file contains the exact set of changes that were computed. A subsequent terraform apply path consumes the file and will not re-plan.
terraform plan -out=tfplan
# Plan output is on stdout AND saved to tfplan
terraform apply tfplan
# No re-plan. No confirmation prompt (because the plan was already approved
# by being saved). Wait — see the apply lesson for the exact confirmation behaviour.
The -out flag is what makes the workflow reviewable across machines. CI saves the plan as an artefact. The deploy job downloads the artefact and applies it. There is no window in which the state could change and cause a re-plan with different changes.
-json
Machine-readable plan, emitted as one JSON object per line on stdout. Used by tools (Atlantis, Spacelift, internal dashboards) that render plans, post comments to pull requests, or block merge based on policy.
terraform plan -json | jq -r 'select(.type == "resource_drift") | .change.resource_address'
The first line of -json output is always a version record; subsequent lines are typed records for resource changes, errors, and progress. Treat the stream as line-delimited JSON, not as a single document.
-target=resource.address
Restricts the plan to a specific resource and its dependencies. The temptation to use it is enormous, and the discipline against it must be stronger.
# Recreate one specific instance without touching the rest.
terraform plan -target=aws_instance.web
The cost: -target truncates the dependency graph. Resources that depend on the targeted resource are also included, but resources that the targeted resource depends on are also included, and any resource outside that closure is ignored. If the targeted resource reads an attribute from a sibling, the sibling may be planned as “no change” while in reality it needs to change.
The rule: -target is for emergencies, not for daily workflow. If you find yourself using it on every plan, the configuration has a structural problem (too many resources in one working directory).
-replace=resource.address
Forces Terraform to destroy and recreate a resource, even if the configuration has not changed. Replaces the deprecated terraform taint.
# Force replacement of a specific instance — for example, after a
# manual certificate upload that cannot be replicated via configuration.
terraform plan -replace=aws_instance.web
The -replace flag is honest about what it does. Unlike the old taint, it shows up in the plan output and produces a -/+ line that a reviewer can see and challenge.
-refresh-only
Plans only the refresh step. Produces a plan that only contains drift items — state that did not match the real world. Used to reconcile state after manual changes without modifying configuration.
# A colleague deleted an instance in the AWS console by mistake.
# Update state to match reality without changing configuration.
terraform plan -refresh-only
-refresh-only is the right tool for absorbing small manual interventions without rewriting every other resource. It produces a plan that is safe to apply because the only changes are state updates.
-refresh=false
Skips the refresh step. Faster, but unsafe in production: any drift that exists in the real world is invisible to the plan.
# Acceptable in CI for a fast lint pass. Not acceptable for a
# plan that a human will review.
terraform plan -refresh=false
The trade-off is real. -refresh=false cuts plan time by half on large estates. Some teams use it in a fast-feedback job and rely on the slow, full-refresh plan as the gate.
-detailed-exitcode
Three exit codes instead of one:
| Exit code | Meaning |
|---|---|
| 0 | Succeeded with no changes (empty plan) |
| 1 | Error |
| 2 | Succeeded with non-empty plan (changes pending) |
terraform plan -detailed-exitcode -out=tfplan
# Exit 0: no changes
# Exit 2: changes pending, plan saved to tfplan
CI uses exit code 2 as the signal that a human must review. Without -detailed-exitcode, exit code 0 means “plan succeeded” and CI cannot distinguish an empty plan from a non-empty one. Every production pipeline must use -detailed-exitcode.
The CI pattern
The pattern is: cheap gates → plan → save → apply. The plan must be saved, uploaded, and consumed by apply:
# CI plan job
set -euo pipefail
terraform init -input=false -backend=false # or full init if plan needs state
terraform validate
terraform plan -input=false -out=tfplan -detailed-exitcode
# If exit code is 2, upload tfplan as a job artefact.
# CD apply job, running on a different machine or later
set -euo pipefail
terraform init -input=false
# Download the saved plan from the previous job's artefacts.
terraform apply -input=false tfplan
The plan file is the contract between the two jobs. Without it, apply re-plans — and the re-plan can differ from the reviewed plan for trivial reasons (a tag was added manually, a new AMI appeared, the AWS API returned slightly different attributes).
The cost of an unsaved plan
The cost is exactly what the saved-plan pattern prevents. A unsaved plan lives in two states:
- In the operator’s terminal. The operator reads the plan, decides it is safe, and runs
terraform apply. Apply re-runs plan internally, refreshes state, and may produce a different plan. The reviewed plan is not the applied plan. - In CI. CI runs
terraform plan, shows the output in the job log, and the next job runsterraform apply. Apply re-plans. The state may have changed between the two jobs because someone else merged a change. The applied plan is not the reviewed plan.
The -out flag eliminates both windows. The plan that was reviewed is the plan that is applied. There is no second refresh in between.
Production failure modes
1. Plan refresh fails on a single resource. Symptom: Error: Failed to refresh state ... AccessDenied or ThrottlingException mid-plan. Cause: the IAM credentials running plan cannot read one specific resource. The rest of the plan is blocked. Recovery: fix the IAM policy for the offending resource type, or exclude it from the working directory using -target for the resources you can read (only as a temporary triage).
2. Plan shows changes after editing a value that “shouldn’t matter”. Symptom: changing a tag value or a description produces a diff in the plan. Cause: the provider schema marks the attribute as computed or as a property that must match exactly. Recovery: investigate before dismissing. A diff is not noise; it is the provider telling you the attribute is part of the resource identity.
3. -target causes the dependency graph to be inconsistent. Symptom: planning with -target produces a plan that says “no changes” for resources that should change. Cause: -target truncated the graph. A resource outside the closure has an output that the targeted resource reads; without that resource in the plan, the targeted resource appears correct against a stale view. Recovery: drop -target and plan the full configuration. Use -target only for isolated emergencies, never for daily workflow.
4. Unsaved plan races with state change. Symptom: a CI plan and apply run on different runners, separated by an hour. Apply shows a different plan than what was reviewed. Cause: no -out. Apply re-plans. Someone else merged or the real world changed in between. Recovery: always use -out. Pass the plan file from the plan job to the apply job as an artefact.
5. -detailed-exitcode not used in CI. Symptom: CI script returns success on every plan run, even when changes are pending. A merge proceeds without human review of a 200-line plan. Cause: plain plan returns 0 on success regardless of changes. Recovery: add -detailed-exitcode and use exit code 2 as the signal for “block merge, require approval”.
6. -replace on a stateful resource without backup. Symptom: -replace=aws_db_instance.primary produces a -/+ line; reviewer approves; apply destroys the database; data is lost. Cause: -replace is a sharp tool. Recovery: never approve a -replace of a stateful resource without confirming backup, snapshot, or explicit data-loss acceptance. RDS: snapshot first. EBS: snapshot first. S3: versioning is the safety net, but only if it was on before the destroy.
Security implications
Plan reads from every provider API for every resource. The IAM role running plan needs read access to every resource type in the configuration. That is broader than the apply role, which needs read plus the specific write actions for the planned changes. The cost of plan-time IAM scope is acceptable, but it should be deliberate: a plan role with *:* is a security smell, even in dev accounts.
The -json output contains sensitive values if the configuration marks them sensitive. Treat plan artefacts as sensitive in CI. Do not post plan JSON to public chat channels; do not store it in artefacts that survive the build.
Performance implications
Plan is network-bound: every refresh call hits a provider API. For a configuration with 500 resources across 30 types, plan takes 60-180 seconds in steady state. Mitigations:
-refresh=falsefor the fast-feedback job (with awareness that drift is invisible).-parallelism=Nto limit concurrent API calls.- Split the configuration into smaller state files so each plan covers a smaller surface.
Plan output is also a function of provider latency. AWS outages during plan cause terraform plan to time out, not to silently produce a stale plan. This is a feature: a plan that fails is better than a plan that lies.
Verification
# Save the plan and apply the saved plan, in two separate steps.
terraform plan -out=tfplan
terraform show tfplan | head -50
terraform apply tfplan
# Verify the JSON form parses and contains expected keys.
terraform plan -json | jq -e 'select(.type == "planned_change")'
# Verify -detailed-exitcode returns 2 for a non-empty plan.
terraform plan -detailed-exitcode; echo "exit: $?"
A healthy run saves a plan file, the apply consumes it without re-planning, and -detailed-exitcode reports the right code. Any deviation is a signal that the plan/apply contract is broken.
What comes next
The next lesson covers terraform apply in depth: the saved-plan execution, the -auto-approve flag, the -parallelism flag, the per-environment gate, and the partial-failure recovery runbook.
Knowledge check · 7 questions
Q1. What does the -/+ symbol in a plan output mean?
Q2. By default, terraform apply re-runs plan and prompts for confirmation even if the operator has already saved a plan file with -out.
Q3. Which exit code does terraform plan -detailed-exitcode return when there are pending changes?
Q4. Which plan flags are appropriate for daily production CI usage? (Select all that apply.)
Q5. You want to force replacement of a specific EC2 instance because you manually rotated its certificate. Which command is correct in Terraform 1.9?
Q6. When should you use terraform plan -refresh-only?
Q7. CI runs plan in job 1 and apply in job 2, on different runners, with a 30-minute gap. Job 2's apply output shows a different plan than job 1. What is the root cause and the fix?
Passing score: 75%. Answers are checked in this browser.