Hedronite · Cert Lesson · Terraform Associate 003 · Wed 2026-10-14

Terraform Associate saved plan files, from plan -out to apply, show -json, and the stale plan

Passing the file is the approval. The state it was built on must not move.

Lesson Class: Cert (T2 · core workflow)
Referent: plan -out · saved plan mode · show -json
Not a repeat: not 10-11 moved · 10-08 lock file · 10-05 refresh-only
Paired Ops: Key Vault diagnostic settings
The File Decides
No new options at apply.
The File Approves
No prompt, no -auto-approve.
The File Is a Secret
Cleartext values inside.
Two plans from one state: the first applied wins.

A plan you review now and apply later is a different object from the plan you approve at the prompt. The exam and your pipeline both depend on knowing how it differs.

§I. Frame

In an interactive terminal, terraform apply hides a gap. It builds a plan, prints it, asks you to type yes, and applies it, all within a few seconds in one process. The plan you read and the plan that runs are the same object, so you never have to ask what happens between them.

Saved plans open that gap. You run a plan at 10:00, a colleague reviews it, and someone applies it at 10:40. In those forty minutes the state may change, someone may pass different variables, and the file may have been copied to a place it should not be. Every behavior in this lesson answers one of those problems.

Two terms first. A speculative plan is what you get from terraform plan without -out. The docs describe it as "a description of the effect of the plan but without any intent to actually apply it." A saved plan is what you get with -out=FILE, which you "can later execute by passing the file to terraform apply as an extra argument." The docs say this two-step workflow "is primarily intended for when running Terraform in automation."

The distinction matters because a speculative plan can go out of date. The plan documentation warns that "other changes made to the target system in the meantime might cause the final effect of a configuration change to be different than what an earlier speculative plan indicated, so you should always re-check the final non-speculative plan before applying." A saved plan lets you apply exactly what was reviewed, and Terraform refuses to apply it if the state it was built against has moved. You will see both halves of that in the lab.

§II. Foundations

What a saved plan is

terraform plan -out=FILE writes the generated plan "in an opaque file format." Two details from the docs are worth memorizing:

That list explains most of the behaviors below. A plan file carries its own copy of the configuration, state and variables, so applying it does not need to re-decide anything.

Two apply modes

terraform apply has two modes, and the apply documentation names them.

Automatic Plan Mode is what you know. With no plan file, Terraform "automatically creates a new execution plan as if you had run terraform plan, prompts you to approve that plan, and performs the indicated operations."

Saved Plan Mode starts when you pass a plan file. Terraform "performs the operations in the saved plan without prompting you for confirmation." There is no approval prompt because the review already happened, at the point where you or a reviewer looked at the plan. The docs point you at terraform show for that: "Use terraform show to inspect a saved plan file before applying it."

Why -auto-approve means nothing here

-auto-approve skips the approval prompt in automatic mode. With a plan file there is no prompt to skip. The docs say Terraform "ignores this option when you pass a previously-saved plan file. This is because Terraform interprets the act of passing the plan file as the approval."

That sentence shapes the safety model. Approval is attached to the file, not to a flag. Whoever can hand a plan file to terraform apply can approve it.

Why extra options are refused

The apply docs say that "when using a saved plan, you cannot specify any additional planning modes or options. These options only affect Terraform's decisions about which actions to take, and the plan file contains the final results of those decisions."

Planning options such as -var, -destroy or -refresh-only shape the decisions made at plan time. The plan file records the outcome of those decisions. If apply accepted new values, the executed change would no longer match the reviewed one. The lab shows what this refusal looks like.

One more fact from the apply docs: "Terraform does not automatically roll back a partially-completed apply." That is one more reason to read a plan before the file goes anywhere.

§III. The lab, step by step

The lab uses only Terraform's built-in terraform_data resource, so there is no provider download and no cloud account. The configuration:

terraform {
  required_version = ">= 1.5"
}

variable "release" {
  type    = string
  default = "v1"
}

variable "db_password" {
  type      = string
  sensitive = true
  default   = "example-not-a-real-secret"
}

# terraform_data is built in: no provider download, no cloud account.
resource "terraform_data" "release" {
  input = var.release
}

resource "terraform_data" "db" {
  input = var.db_password
}

output "release" {
  value = terraform_data.release.output
}

The transcript comes from run.sh on Terraform v1.14.3 (darwin_arm64) with local state. The script exports TF_IN_AUTOMATION=1, and every [exit N] line is the real exit code. The exam page says the 004 exam tests Terraform version 1.12. The lab ran on 1.14.3, not 1.12, so treat the exact error wording below as that version's text.

1. Save a plan, then read it back

$ terraform plan -input=false -out=v1.tfplan
...
  # terraform_data.db will be created
  + resource "terraform_data" "db" {
      + id     = (known after apply)
      + input  = (sensitive value)
      + output = (known after apply)
    }

  # terraform_data.release will be created
  + resource "terraform_data" "release" {
      + id     = (known after apply)
      + input  = "v1"
      + output = (known after apply)
    }

Plan: 2 to add, 0 to change, 0 to destroy.
...
[exit 0]

-input=false tells Terraform not to prompt for root module variables, which matters in automation where nobody can answer. The terminal output hides the password as (sensitive value). Keep that in mind for §IV.

Then:

$ terraform show v1.tfplan
...
Plan: 2 to add, 0 to change, 0 to destroy.
...
[exit 0]

terraform show printed the same plan, read from the file. This is the review step: the reviewer reads the exact plan that apply will run, without planning again.

2. The JSON view

$ terraform show -json v1.tfplan | jq (resource_changes)
{"address":"terraform_data.db","actions":["create"]}
{"address":"terraform_data.release","actions":["create"]}

For plan files, the docs say terraform show -json "shows a JSON representation of the plan, configuration, and current state." Here jq pulled out each resource address and its list of actions. This is the form a script or policy tool can read, which §V picks up. One caveat from the show docs: "If you are viewing a plan, it must be created without -refresh=false."

3. What is inside the file

$ unzip -l v1.tfplan
tfplan
tfstate
tfstate-prev
tfconfig/m-/main.tf
tfconfig/modules.json
.terraform.lock.hcl

In this version, unzip could list the plan file. It held entries named tfplan, tfstate and tfstate-prev, a copy of main.tf under tfconfig/, a module manifest and the dependency lock file. That is what this version wrote. The docs call the format opaque, so do not build tools that depend on this layout. Use terraform show and terraform show -json, which are the supported ways in. The listing is useful as evidence for the docs' claim that the file contains "your full configuration" and the state it applies to.

4. The secret is in the file

$ grep -c example-not-a-real-secret (plan file, show -json)
2
1

The first count searches the unzipped plan file, and the second searches the show -json output. grep -c counts matching lines, not occurrences, so read these as "found" rather than as precise tallies. Both found the sensitive default example-not-a-real-secret, even though the terminal plan printed (sensitive value). §IV covers what follows from this.

5. Apply with a new variable: refused

$ terraform apply -input=false -var release=v9 v1.tfplan

Error: Value for undeclared variable

A variable named "release" was assigned on the command line, but the root
module does not declare a variable of that name. To use this value, add a
"variable" block to the configuration.
[exit 1]

The message is misleading. release is declared in main.tf. Do not go looking for a missing variable block.

The real reason is the one from the apply docs: a saved plan already contains its variables and options, so no new ones are accepted. In saved plan mode, -var is an unexpected extra option, and this version's error wording for it happens to talk about undeclared variables. If a question or a pipeline log shows this error alongside a plan file argument, the fix is to re-plan with the values you want, then apply the new file.

6. Apply with no prompt

$ terraform apply -input=false v1.tfplan
terraform_data.release: Creating...
terraform_data.db: Creating...
terraform_data.release: Creation complete after 0s [id=2ee5f8b3-640d-9467-7bb6-c153d15ec442]
terraform_data.db: Creation complete after 0s [id=6cbf2a03-5022-22fe-115e-a78bc8ae445b]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

release = "v1"
[exit 0]

No Do you want to perform these actions? and no -auto-approve. Passing the file was the approval. The result is the 2 additions the earlier plan displayed and nothing else.

7. Two plans from one state, and the stale one

Now the state holds release = "v1". The script saves two plans from that same state, with -detailed-exitcode on each:

$ terraform plan -input=false -detailed-exitcode -var release=v2 -out=v2.tfplan
...
  # terraform_data.release will be updated in-place
  ~ resource "terraform_data" "release" {
        id     = "2ee5f8b3-640d-9467-7bb6-c153d15ec442"
      ~ input  = "v1" -> "v2"
      ~ output = "v1" -> (known after apply)
    }

Plan: 0 to add, 1 to change, 0 to destroy.
...
[exit 2]

$ terraform plan -input=false -detailed-exitcode -var release=v3 -out=v3.tfplan
...
      ~ input  = "v1" -> "v3"
...
[exit 2]

Exit code 2 is the -detailed-exitcode signal for "Succeeded with non-empty diff (changes present)." The docs list the full set: 0 for an empty diff, 1 for an error, 2 for changes present. Both plans are valid on their own, built from the same starting state.

Apply v3.tfplan first:

$ terraform apply -input=false v3.tfplan
terraform_data.release: Modifying... [id=2ee5f8b3-640d-9467-7bb6-c153d15ec442]
terraform_data.release: Modifications complete after 0s [id=2ee5f8b3-640d-9467-7bb6-c153d15ec442]

Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
...
release = "v3"
[exit 0]

Now try v2.tfplan, still sitting on disk:

$ terraform apply -input=false v2.tfplan

Error: Saved plan is stale

The given plan file can no longer be applied because the state was changed by
another operation after the plan was created.
[exit 1]

Applying v3 changed the state. The v2 file claimed to start from the old state, and Terraform refused to run it against the new one. The guard is the point of the design: without it, an old plan could silently overwrite a newer change.

The check is not about which plan is newer or which version number is bigger. It is about which plan was applied first. Had you applied v2 first, v3 would be the stale one. This is the situation the automation tutorial describes: "When a plan is applied, any other existing plans that were produced against the same state are invalidated, since they must now be recomputed relative to the new state." It recommends "allow only one plan to be outstanding at a time." The recovery is to plan again.

8. Plan again

$ terraform plan -input=false -detailed-exitcode -var release=v3
...
No changes. Your infrastructure matches the configuration.
...
[exit 0]

Exit 0, no changes. This plan has no -out, so it is speculative, and it confirms that state and configuration agree. In a pipeline, exit code 0 means there is nothing to apply.

§IV. Plan files are secrets

The lab already showed the problem: the terminal said (sensitive value), and the file did not hide anything.

The plan docs are direct about it: "If your plan includes any sort of sensitive data, even if obscured in Terraform's terminal output, it will be saved in cleartext in the plan file. You should therefore treat any saved plan files as potentially-sensitive artifacts." The sensitive = true setting in main.tf controls what Terraform prints. It does not control what Terraform writes into the file.

The show docs add the same warning for the JSON view: "When using the -json command-line flag, any sensitive values in Terraform state will be displayed in plain text." So terraform show -json plan is a second way to reach the same data. Pasting that output into a ticket or a chat posts the secret.

Brikman, in Terraform: Up and Running (3rd ed.), puts it this way: "any secrets you pass into your Terraform resources and data sources will end up in plain text in your Terraform plan files!" He gives two rules:

  1. Encrypt your Terraform plan files, in transit and on disk.
  2. Strictly control who can access your plan files.

One related habit from the automation tutorial: "For provider authentication credentials, it is recommended to use environment variables instead where possible since these are not included in the plan." The file holds the configuration, the state and the variables passed to terraform plan. Credentials that arrive through the environment stay out of it.

The practical rule is to give a plan file the same access controls as the state file, because it holds the same values and more.

§V. Plans in automation

The tutorial Running Terraform in automation lays out a four-step path: initialize, produce a plan, have a human operator review that plan, apply the changes described by the plan. The commands:

terraform init -input=false
terraform plan -out=tfplan -input=false
terraform apply -input=false tfplan

The review step sits between the second and third commands. That is the step the saved plan makes possible.

Some supporting details:

Moving a plan between machines

If the plan and apply run on different machines, the tutorial gives constraints, all of which follow from the file's contents:

Plans that are never applied

For pull request checks, the tutorial says "the -out option is not used in this scenario because a plan produced for code review purposes will never be applied." A plan for review is speculative by design. There is no reason to create a file that would then need protecting.

HCP Terraform

Remote execution works differently: "You cannot apply a saved plan to HCP Terraform remote operations. You can review probable infrastructure changes by triggering a speculative plan, but speculative plans are not applyable."

JSON for policy checks

Brikman's book shows the pattern for testing plans with OPA: terraform plan -out tfplan.binary, then, because "OPA only operates on JSON," convert the plan with terraform show -json tfplan.binary > tfplan.json. The show -json output from step 2 is how a saved plan reaches a policy engine. That output carries the same plain-text values as §IV warns about, so the JSON file needs the same handling as the plan file.

§VI. Exam lens

This material serves these objectives on the current exam content list (the 004 list, which the old 003 review URL now serves):

On the older 003 list, this material sits under the core workflow objective. The 004 page states that the exam "Tests on Terraform version 1.12."

Traps to watch for:

  1. Speculative versus saved. terraform plan alone makes a speculative plan with no intent to apply. Only -out=FILE produces something apply can run later.
  2. **-auto-approve with a plan file.** It is ignored. Passing the file is the approval, and no prompt appears.
  3. **-var or -target with a saved plan.** Apply accepts no additional planning modes or options, because the plan holds the final results of those decisions. The lab tested -var and got exit 1. The apply docs name -destroy and -refresh-only as planning modes. The docs' reason (options that only affect which actions to take) is what you would apply to -target in a question, but this lab did not run -target.
  4. The stale plan. Two plans from one state: the first one applied wins, and the second fails with "Saved plan is stale." Re-plan.
  5. **A .tf suffix on a plan file.** Terraform would try to read it as configuration. tfplan is the conventional name.
  6. Sensitive values. sensitive = true hides terminal output only. The plan file holds the value in cleartext, and so does terraform show -json.

§VII. Practice Questions

Question 1
You run terraform plan with no extra options and save nothing. What kind of plan did you produce? A) A saved plan that apply will pick up automatically B) A speculative plan, a description of the effect without any intent to apply it C) An opaque file named tfplan D) A plan that locks the state until you apply
tap to reveal
Answer: B. Without -out=FILE, Terraform creates a speculative plan. Only a plan written with -out can be passed to terraform apply later.
Question 2
A pipeline runs terraform apply -auto-approve tfplan, where tfplan was created by terraform plan -out=tfplan. What does -auto-approve do? A) Skips the approval prompt that would otherwise appear B) Re-plans before applying C) Is ignored, because passing a saved plan file is itself the approval D) Causes an error because the two cannot be combined
tap to reveal
Answer: C. Terraform ignores -auto-approve for a previously saved plan file, since it interprets the act of passing the file as the approval. No confirmation prompt appears either way.
Question 3
After terraform plan -out=v1.tfplan, someone runs terraform apply -var release=v9 v1.tfplan and gets exit code 1. What is the underlying reason? A) The variable was never declared in the configuration B) A saved plan already contains its variables and options, so apply accepts no additional ones C) -var is only valid with -auto-approve D) The plan file must be renamed first
tap to reveal
Answer: B. The error text in Terraform 1.14.3 says "Value for undeclared variable", which is misleading when the variable is declared. The real cause is that a plan file holds the final results of its decisions, so you re-plan with the new value.
Question 4
Two plans, a.tfplan and b.tfplan, were saved from the same state. You apply b.tfplan successfully, then run terraform apply a.tfplan. What happens, and what should you do? A) It applies, because both plans were valid when created B) It fails as a stale plan, because the state changed after the plan was created, so create a new plan C) It applies only the differences from b.tfplan D) It prompts you to choose which plan wins
tap to reveal
Answer: B. Applying one plan invalidates other plans produced against the same state, and Terraform reports "Saved plan is stale." Keep only one plan outstanding at a time and re-plan against the new state.
Question 5
A variable is marked sensitive = true, and the terminal plan shows (sensitive value). Which statement about the saved plan file is correct? A) The value is encrypted inside the file B) The value is omitted from the file C) The value is stored in cleartext in the file, and terraform show -json also displays it in plain text D) The value is only stored if -var was used
tap to reveal
Answer: C. Sensitive data is saved in cleartext in the plan file even though the terminal output obscures it, so treat plan files as sensitive artifacts. Encrypt them and control access.
Question 6
A CI job runs terraform plan -detailed-exitcode and the exit code is 2. What does that mean? A) An error occurred B) The plan succeeded with an empty diff C) The plan succeeded and changes are present D) The plan file is stale
tap to reveal
Answer: C. The exit codes are 0 for an empty diff, 1 for an error, and 2 for a successful plan with a non-empty diff. In the lab, both v2.tfplan and v3.tfplan returned 2.

§VIII. Closing

Saving a plan splits one interactive moment into two events, review and apply, and the rules in this lesson hold the two together. The file carries the decisions, so apply accepts no new options. The file counts as the approval, so no prompt appears. The state it was built against must not have moved, so a stale plan is refused. And since the file holds your configuration, state and variables in the clear, it deserves the same care as the state itself.

Related