Terraform Professional: Planning and Applying Infrastructure Changes
Planning and applying changes in Terraform means creating a reviewable proposal with terraform plan, usually saved with -out, and then executing exactly that proposal with terraform apply. At the professional level the work is less about typing the two commands and more about operating them safely in production: reading plan symbols and the JSON plan, keeping a reviewed plan valid across CI jobs, scripting on exit codes, limiting scope with -target only when justified, running non-interactively without collisions, recovering from an apply that fails partway, and using speculative and saved plans in HCP Terraform.
On this page10 sections
- What does terraform plan do, and which options shape it?
- How do you read the symbols in a plan?
- How does a saved plan make apply do exactly what was reviewed?
- How do you hand a saved plan from one CI job to another?
- How do scripts and policy gates read a plan?
- What does -target include, and when is it acceptable?
- How do you plan and apply the destruction of infrastructure?
- Which flags keep unattended runs from hanging or colliding?
- What happens when an apply fails or is interrupted partway?
- How do planning and applying work in HCP Terraform?
- Read plan output symbols, replacement order and deferred data source reads, and map them to the JSON plan's actions and action reasons
- Use saved plans so the apply executes exactly what was reviewed, and explain what a saved plan accepts, refuses and why it goes stale
- Hand a saved plan between CI jobs safely and protect plan files that contain sensitive values in cleartext
- Gate automation on -detailed-exitcode and on terraform show -json fields such as actions, after_unknown and after_sensitive
- Apply -target, destroy planning and configuration removal correctly, and run unattended applies with the right input, approval, locking and parallelism settings
- Recover from partial or interrupted applies, and choose between speculative plans, saved cloud plans and VCS-driven runs in HCP Terraform
What does terraform plan do, and which options shape it?
terraform plan compares your configuration with the current state and the real remote objects, then proposes the set of create, update, replace, delete and read actions that would make infrastructure match the configuration. It changes nothing: by default it refreshes its view of remote objects in memory, prints the proposal and exits.
A plan is shaped by planning modes and options on one side and run options on the other, and keeping the two groups apart explains most of how saved plans behave later:
| Kind | Examples | What it decides |
|---|---|---|
| Planning modes | -destroy, -refresh-only (normal mode is the default) | Whether the plan aims at the configuration, at destroying everything, or only at updating state to match reality |
| Planning options | -var, -var-file, -target, -replace, -refresh=false | What the plan contains |
| Run options | -input=false, -lock-timeout, -lock=false, -parallelism, -compact-warnings (shortens how warnings are printed), -json | How the command runs (prompts, locking, concurrency, output format) |
-refresh=false skips re-reading remote objects and plans from state alone. It is faster but can miss drift, and it is still a normal plan: it never proposes deletions for resources the configuration still declares. Refresh-only plans, -replace and drift handling are covered in the state and drift lesson of this course.
-out=FILE saves the proposal for a later terraform apply FILE. Without -out, the plan is a preview only. terraform show FILE renders a saved plan for people, and terraform show -json FILE renders it for tools.
How do you read the symbols in a plan?
Each resource in a plan is prefixed with a symbol that says what will happen to it, and arguments inside it carry their own markers:
| Symbol | Meaning | JSON change.actions |
|---|---|---|
+ | Create | ["create"] |
~ | Update in place | ["update"] |
- | Destroy | ["delete"] |
-/+ | Replace: destroy, then create the replacement (the default) | ["delete","create"] |
+/- | Replace: create the replacement first, then destroy the old object | ["create","delete"] |
<= | Read a data source | ["read"] |
A # forces replacement comment marks the argument whose change cannot be made in place (for example an image ID or an availability zone). The header line says must be replaced. Which replacement order you get depends on the resource's lifecycle settings:
create_before_destroy = trueturns the default-/+into+/-, so the new object exists before the old one is removed (useful for any object that clients or other resources must never lose, even briefly).replace_triggered_byadds reasons to replace but does not change the order.prevent_destroy = truemakes any plan that would destroy the object fail with an error.ignore_changeson the argument removes the change from the plan, so no replacement is proposed.
When is a data source read during apply instead of during plan?
Terraform normally reads data sources while planning, so they do not appear as actions. A data source shows as <= with values (known after apply) when its read is deferred to apply, currently for one of two reasons, which terraform show -json records as the change's action_reason:
action_reason | Cause |
|---|---|
read_because_config_unknown | One of the data source's own arguments is unknown at plan time (for example the ID of a resource being created). |
read_because_dependency_pending | Every argument is known, but the data source depends on a managed resource that has changes pending in this plan, so Terraform waits until that change is applied. |
The two rows turn on different questions: the first on whether an argument value is unknown, the second on whether anything the data source depends on is still waiting to change. A pending dependency triggers the second reason even when every value the data source uses is already known. Anything computed from a deferred read is itself unknown until apply.
How does a saved plan make apply do exactly what was reviewed?
Running terraform plan -out=FILE and later terraform apply FILE applies exactly the operations recorded in the file, with no re-planning. That is the only way to guarantee that the apply step changes what the approver reviewed, even if new commits land between the two steps. terraform apply without a file argument always builds a fresh plan from whatever configuration is checked out.
What a saved plan carries and refuses:
- No approval prompt. Passing the file is the approval, so apply does not ask for
yes.-auto-approveadds nothing in this mode. - Recorded variable values. Input variables, sensitive ones included, are resolved at plan time and stored in the file, except variables declared
ephemeral, which are never written to the plan. Apply does not ask for them again and does not re-readTF_VAR_*environment variables or.tfvarsfiles. - No planning modes or options.
-var,-var-file,-target,-replace,-refresh=falseand-destroyare rejected. To change a value or the scope, create a new plan and send that through review. - Run options are accepted.
-parallelism,-lock-timeout,-input=falseand output options change how the plan is executed, not what it contains. For exampleterraform apply -parallelism=5 change-1234.tfplanreduces concurrency for an apply that is hitting API rate limits without touching the reviewed changes.
Why does Terraform reject a stale saved plan?
A plan is computed against a specific state. If any other operation writes that state after the plan was created (another pipeline's apply, a manual import or state move, a partially failed apply of this very plan), Terraform refuses to apply the file with a "Saved plan is stale" error rather than apply changes calculated from outdated data.
Locking does not help here: -lock-timeout lets a second apply wait for the lock, but once it gets the lock the plan is still stale. Terraform never merges or re-plans a saved plan. Nothing done to the old file revives it either: terraform refresh writes state again, and rolling state back to an older version makes it disagree with real infrastructure. The only fix is a new plan against the current state and a new review.
How do you hand a saved plan from one CI job to another?
Apply the plan with the same working directory it was created in. HashiCorp's guidance for running Terraform in automation is to archive the entire working directory after planning, including the .terraform subdirectory that terraform init created (installed providers, modules, backend settings) and the dependency lock file, and restore it in the apply job.
The handoff fails, or is unsafe, when:
- Versions differ. Terraform errors if Terraform itself or any provider plugin was upgraded between plan and apply. Pin the Terraform binary to the same version in both jobs, and never run
terraform init -upgradein the apply job. - The directory is not initialised. Uploading only the plan file to a fresh runner leaves it without providers or backend configuration.
- The platform differs. The plan can contain absolute paths and assumes the same operating system and CPU architecture. Restore the archived working directory at the identical absolute path on a runner with the same OS and architecture.
Settings such as TF_IN_AUTOMATION or extra flags at apply time do not make a moved plan acceptable. The JSON rendering of a plan cannot replace the binary file either: it is for inspection and policy checks, and terraform apply cannot execute it.
Why must plan files be treated as secrets?
Plan files store the real values needed to apply, including values from variables and attributes marked sensitive = true, in cleartext. The (sensitive value) placeholder is a feature of the human-readable display only; Terraform neither encrypts nor hashes those values in the file. The same holds for terraform show -json output, which keeps the value in after and only flags it in after_sensitive.
So restrict who can download plan artifacts, keep them only as long as the apply needs them, and never attach the JSON plan to a pull request or ticket where it would publish secrets.
How do scripts and policy gates read a plan?
Scripts should branch on the plan's exit code, and policy gates should parse terraform show -json, never the human-readable text.
What do the -detailed-exitcode codes mean?
| Exit code | Without -detailed-exitcode | With -detailed-exitcode |
|---|---|---|
| 0 | Success, with or without changes | Success, no changes |
| 1 | Error | Error |
| 2 | Not used | Success, changes present |
Two consequences matter in practice. Under set -e, exit code 2 is non-zero, so a successful plan with changes aborts the script unless the code is captured (for example terraform plan -detailed-exitcode -out=tfplan || rc=$?) and only 1 treated as failure. And a drift job that treats every non-zero exit as "changes pending" turns errors, such as a syntax error or an unreachable backend, into false drift alerts; branch explicitly on 0, 2 and everything else. Warnings never change the exit code, and -detailed-exitcode works together with -out.
Which JSON fields should a policy gate check?
Each entry in resource_changes[] has a change object:
actions: the list from the symbol table above. Replacement is encoded as["delete","create"]or["create","delete"]precisely so that a single "containsdelete" test catches pure deletes and both replacement orders. An exact match on["delete"]misses replacements.before/after: the prior and planned values.beforeisnullfor a create.after_unknown: same shape asafter, withtruefor values known only after apply. Those values are omitted fromafter, so a gate that tests a planned attribute inaftermust first look up the same path inafter_unknownand decide its policy for unknowns.before_sensitive/after_sensitive: same shape,truewhere a known value is sensitive. Combine them withafterto avoid displaying secrets.
The optional action_reason (for example replace_because_cannot_update, delete_because_no_module, the deferred-read reasons) explains a change, but HashiCorp documents these codes as hints for display whose set may change, so do not base policy on them. resource_drift lists changes detected outside Terraform, not the changes this plan proposes.
What does -target include, and when is it acceptable?
-target=ADDRESS limits planning to the matching resource instances and everything they depend on. It never includes dependents: resources that reference the target are left out.
- Targeting a database instance that references a subnet group and a parameter group can plan all three; an alarm and a DNS record that reference the database are excluded.
- Targeting a module (
-target=module.storage) includes every resource in that module, changed or not, plus their dependencies. A module that consumes its outputs (module.analytics) is not planned, so its edits and any knock-on changes stay pending.
Terraform warns that a targeted plan may be incomplete. HashiCorp reserves -target for exceptional circumstances, such as pushing one fix during an incident or recovering from a mistake, because routine use leaves changes undetected. Targeting is per run: nothing is stored in state, later runs are not limited, and a targeted apply locks state like any other. After a targeted apply, run a full plan without -target to see and deal with everything that still differs.
-target is a planning option, so it cannot be added when applying a saved plan.
How do you plan and apply the destruction of infrastructure?
Destroy is a planning mode. terraform plan -destroy -out=decommission.tfplan proposes deleting every object the configuration manages and saves the proposal for review; terraform apply decommission.tfplan then executes it. terraform destroy is an alias for terraform apply -destroy, which plans and applies in one command; neither it nor terraform apply has an -out option.
To retire part of a stack, such as a module call the business no longer needs, remove its block from the configuration and run the normal plan, review and apply. The plan proposes deleting the module's objects (action reason delete_because_no_module, or delete_because_no_resource_config for a removed resource block), and because they are no longer declared, later plans stay clean.
| Approach | Objects deleted? | Next plan |
|---|---|---|
| Remove the block, plan, review, apply | Yes | Clean |
plan -destroy -target=module.x, then apply | Yes | Proposes creating them again (still declared) |
terraform state rm | No, only forgotten | Proposes creating them again if still declared |
removed block with destroy = false | No, only forgotten | Clean, objects keep running unmanaged |
The details of removed and moved blocks belong to the import and lifecycle lesson of this course.
Which flags keep unattended runs from hanging or colliding?
An unattended run must never wait for a person, and two runs must never write the same state at once. The relevant settings each solve one problem:
| Setting | What it does | What it does not do |
|---|---|---|
-input=false | Errors on a missing variable value instead of prompting | Skip the apply confirmation |
-auto-approve (apply only) | Skips the yes prompt of an apply that builds its own plan | Anything when a saved plan is passed (there is no prompt); it never re-plans. terraform plan has no such option |
TF_IN_AUTOMATION (any non-empty value) | Adjusts output for automation, such as dropping "run terraform apply next" hints | Suppress prompts or affect which plan files are accepted |
-lock-timeout=DURATION (for example 5m) | Retries acquiring the state lock for that long (default 0s: fail at once) | Reduce API calls or revive a stale plan |
-lock=false | Disables state locking | Anything safe when others may run against the same state |
-parallelism=n | Caps concurrent operations while walking the graph (default 10), easing API throttling such as HTTP 429 | Wait for another run's lock |
TF_LOG | Sets internal debug logging | Change prompts or normal command output |
Where -auto-approve belongs is a design decision, not a convenience. In a disposable sandbox that nobody reviews, -input=false -auto-approve on a plain apply is acceptable. In production, apply a reviewed saved plan, which needs no approval flag at all. Pipeline design beyond these flags is covered in the CI/CD lessons of this course.
What happens when an apply fails or is interrupted partway?
An apply is not transactional. Terraform writes each completed operation to state as it goes and never rolls back on error, so after a failure:
- Completed changes stay, both in the cloud and in state. Restoring an older state version would orphan the real objects.
- Unstarted changes are simply absent. State holds real objects only; there is no "pending" status.
- An object created but not finished is tainted. If a create fails after the provider made the object (for example a cluster whose creation timed out), or a creation-time provisioner fails, Terraform records the object as tainted because it may be partly configured. The next plan proposes replacing it, even with no configuration change.
- The saved plan is now stale, because the apply changed the state. It cannot be re-applied.
Recovery is therefore: fix the root cause, create a new plan (which proposes only what is left, plus replacement of tainted objects), review it and apply it. Nothing needs importing, because the completed objects are already tracked.
What does Ctrl-C do during an apply?
The first interrupt asks for a graceful stop: Terraform starts no new operations, lets in-flight operations finish, writes their results to state and exits with an error. Nothing is rolled back. A second interrupt forces an immediate exit, which can leave objects created in the cloud but missing from state, so avoid it.
How do planning and applying work in HCP Terraform?
With a cloud block (or the remote backend) and remote execution, terraform plan and terraform apply start runs in HCP Terraform and stream their output to your terminal; the operation runs with the workspace's variables and credentials, not the laptop's. Only local execution mode plans on the machine you run from.
| Run type | How it starts | Can it apply? |
|---|---|---|
| Speculative plan | terraform plan without -out from the CLI, or a pull request against a VCS-linked workspace's tracked branch | Never |
| Saved cloud plan | terraform plan -out=FILE (Terraform CLI 1.6 or later), then terraform apply FILE | Yes, only in the workspace that created it |
| Plan-and-apply run | terraform apply from the CLI, or a merge or push to the tracked branch | Yes, after confirmation or auto-apply |
For a VCS-driven workspace, every pull request against the tracked branch gets a speculative plan whose result is linked in the pull request, re-running as the pull request updates. Merging queues the real run. That is the reviewed path for these workspaces, because remote saved plans and remote terraform apply from the CLI are available only in workspaces without a linked VCS repository. A CLI that is new enough does not change this.
Other workspace features do different jobs: auto-apply applies successful runs on the tracked branch without confirmation, run tasks call external services during a run, and run triggers queue runs after another workspace applies. None of them makes a run plan-only.
Tip. Expect production scenarios decided by a stated constraint: the apply must match the reviewed plan exactly, no concurrent state writes, a plan file must move between CI runners, secrets must not leak, or the workspace is VCS-driven. Many items show a plan excerpt, a JSON field or a short shell script and ask what Terraform will do, which flag fits, or which option is accepted with a saved plan. Wrong options are usually real flags and commands that solve a neighbouring problem, so know what each one does and does not do; some items ask you to select two correct statements.
- terraform apply FILE executes exactly the saved plan: no approval prompt, recorded variable values (sensitive ones included), no re-planning and no planning options such as -var, -target or -refresh=false.
- Run options such as -parallelism, -lock-timeout and -input=false are accepted with a saved plan because they change how it runs, not what it contains.
- Any write to state after planning makes a saved plan stale; the only fix is a new plan and a new review, never refresh, state rollback or waiting on the lock.
- Hand plans between jobs with the whole initialised working directory (.terraform and the lock file), the same Terraform and provider versions and the same OS/architecture; never init -upgrade before applying.
- Plan files and their JSON rendering hold sensitive values in cleartext; restrict and expire them like secrets.
- With -detailed-exitcode, 0 = no changes, 1 = error, 2 = changes; scripts must branch on 1 and 2 separately.
- Policy gates test whether change.actions contains "delete" (covering both replace encodings), check after_unknown before reading after, and never rely on action_reason.
- -target includes dependencies, never dependents, is for exceptional cases only, and should be followed by a full plan.
- Apply is not transactional: completed changes stay in state, failed creates are tainted, the first Ctrl-C stops gracefully, and recovery is a fresh reviewed plan.
- In HCP Terraform, plain CLI plans and pull-request plans are speculative; saved cloud plans (CLI 1.6+) and CLI applies work only in workspaces without a linked VCS repository.
Frequently asked questions
Do I need -auto-approve when applying a saved Terraform plan?
No. When you run terraform apply with a saved plan file, Terraform treats the reviewed plan as the approval and applies it without a yes prompt. -auto-approve only matters for an apply that builds its own plan, and adding it to a saved-plan apply changes nothing.
Why does terraform apply say my saved plan is stale?
The state was written by another operation, such as another pipeline's apply or a manual import, after the plan was created. Terraform refuses to apply changes computed from outdated state. Create a new plan against the current state, review it and apply that file.
Can I pass -var or -target when applying a saved plan?
No. Variable values and scope are fixed when the plan is created, so planning options such as -var, -var-file, -target, -replace and -refresh=false are rejected with a plan file. Run options such as -parallelism and -lock-timeout are accepted.
Are sensitive values hidden in a Terraform plan file?
No. The (sensitive value) placeholder only appears in human-readable output. The plan file stores sensitive values in cleartext, and terraform show -json includes them in after while flagging them in after_sensitive, so both must be handled as secrets.
What do the exit codes of terraform plan -detailed-exitcode mean?
0 means the plan succeeded with no changes, 1 means an error occurred, and 2 means the plan succeeded and changes are present. Without the flag, a successful plan exits 0 whether or not there are changes.
Does terraform plan -target include resources that depend on the target?
No. -target includes the addressed resources or module and everything they depend on, but not resources that reference them. Their pending edits stay unplanned, which is why a full plan without -target should follow a targeted run.
Does Terraform roll back if an apply fails halfway?
No. Terraform records each completed operation in state as it goes and does not roll back. Completed changes stay, an object whose creation failed after it was made is marked tainted, and the next plan proposes what is left, including replacing tainted objects.
Can I create a saved plan in an HCP Terraform workspace linked to GitHub?
No. Saved cloud plans (Terraform CLI 1.6 or later) and remote applies from the CLI are only available in workspaces without a linked VCS repository. A VCS-driven workspace takes changes through its repository: pull requests get speculative plans and merges queue the real run.
Source
This lesson covers the "Infrastructure lifecycle and operations" domain of the official Terraform Pro 002 exam guide. Vendors revise their guides — check the source for the current version.
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.