Background

Plan Apply Lifecycle

37 min read

A plan is not a list of the things you changed. It's the output of a computation over three inputs — your configuration, Terraform's record of what it made, and what the provider says is really there — and once you know the six phases of that computation, every surprising plan becomes explicable. This page is those phases, the symbols they produce, and the two ideas that account for most confusing plan output: unknown values, and replacement.

Prerequisites: Resources & References, Anatomy of a Project


What & Why

The plan/apply lifecycle is the pipeline Terraform runs to turn a configuration into a set of proposed actions, and then to execute them: parse the configuration, resolve its values, refresh state against reality, build a dependency graph, diff each resource, render the result — and on apply, walk the graph performing the actions.

The bad practice it replaces

Applying and finding out. Not because engineers are reckless, but because in most tooling there was nothing else to do: a deployment script's behaviour was knowable only by reading its source and simulating it, and the simulation was in your head and therefore wrong.

The more specific bad practice this page attacks is one Terraform users fall into: reading only the summary line. Plan: 3 to add, 1 to change, 0 to destroy is genuinely useful and it is not enough, because it cannot tell you that the one change is a replacement of a database, or that the three adds include a resource you thought already existed. The plan body is where that lives, and skimming it is the habit that makes an incident out of a routine change.

Where it sits

This page is the what happens, in order. It deliberately stops at two edges. The graph phase gets a sentence here and a page of its own in The Dependency Graph, because "what creates an edge" is a big enough question to deserve one. And the ways you deliberately intervene in the diff — lifecycle, create_before_destroy, ignore_changes — are Meta-Arguments.

Three things it's confused with

A plan is not a transaction. Nothing is reserved, locked or staged in your cloud account. It's a prediction, and the world can move between the prediction and the execution.

A plan is not a diff of your .tf files. This is the most consequential confusion on the page. Git shows you what you changed. A plan shows the difference between your configuration and reality, which includes changes nobody made in code: drift, provider upgrades changing defaults, and resources deleted out of band. A plan with no code change is not necessarily an empty plan.

plan and apply are not "compile" and "run". Both do the same first five phases. apply without a saved plan file re-does all of them from scratch — so an apply is a plan plus execution, not a continuation of the plan you looked at earlier.

When NOT to trust it

Three cases where a plan is telling you less than it appears to:

  • When you ran it with -refresh=false. Fast, and it computes the diff against recorded state rather than reality — so it cannot see drift, and will happily report "no changes" about infrastructure someone deleted yesterday. Legitimate for a quick iteration loop on a large state; never the basis for a production approval.
  • When the plan is old. A saved plan file is a decision frozen at a moment. Applying one from this morning is applying this morning's understanding of reality.
  • When the interesting values are unknown. A plan that shows (known after apply) where you needed a real answer hasn't lied to you, but it hasn't told you what you wanted either — and unknowns propagate, so one unknown value upstream can hide the actual shape of a change several resources downstream.

Core Concepts

Planthe proposed change. A computed set of actions, one per resource instance, derived from comparing configuration, prior state and refreshed reality. Also the noun for the rendered output.

Prior statewhat Terraform recorded last time. The contents of the state file at the start of the run, before any refresh.

Refreshed statewhat the provider says is there now. Prior state updated with the current attributes read from the provider's API. The diff is computed against this, not against prior state.

Refreshre-reading reality. One provider API read per managed resource instance. On by default for plan and apply; disabled with -refresh=false; performed alone, with the diff suppressed, by plan -refresh-only.

Actionwhat Terraform intends to do to one resource instance. One of: create, read, update in place, replace (destroy-then-create or create-then-destroy), destroy, or no-op.

Replacementdestroy and create, because the change can't be made in place. Triggered when a changed attribute is marked in the provider's schema as requiring a new resource. The single most dangerous thing a plan can propose, because for data-bearing resources it is data loss wearing the costume of an update.

In-place updatemodify the existing object. The provider issues an update call; the object keeps its identity and its data.

Unknown valuea value that cannot be computed until apply. Rendered (known after apply). Any attribute whose value is produced by the provider — an ARN, a generated ID, an IP address — is unknown at plan time for a resource that doesn't exist yet.

Unknown propagationunknowns are contagious. An expression containing an unknown value is itself unknown, so an unknown attribute on one resource makes every attribute derived from it unknown too, however far downstream.

Plan-time versus apply-time evaluationwhen a value gets decided. Expressions over variables, locals, data sources and already-known state attributes are resolved at plan time. Expressions over not-yet-created resources' computed attributes are resolved during apply.

Saved plan filea plan frozen for execution. Produced by plan -out=FILE, consumed by apply FILE, inspected by show FILE or show -json FILE. Binary, and it contains sensitive values in plaintext.

Stale plana saved plan whose assumptions no longer hold. apply on one fails rather than adapting, because silently doing something other than what was approved is worse than an error.

Resource addressthe identifier the whole lifecycle speaks in. aws_s3_bucket.demo, or with an instance key, aws_s3_bucket.demo["logs"]. Every plan line, every state entry and every error message uses it.


How It Works

Six phases. plan runs one to six; apply runs one to six and then seven.

Diagram of the six phases of a Terraform plan, from parsing configuration through rendering the diff, with apply appended as a seventh phase

1 · Parse and validate

Every .tf file in the working directory is read and parsed into a syntax tree, then checked for internal consistency: do referenced resources exist, are argument types correct against the provider schema, are required arguments present. This is the phase terraform validate runs on its own, and the reason it needs no credentials — nothing has talked to a cloud yet.

Note what parsing doesn't do: it doesn't execute anything in order. A .tf file is evaluated, not run, so a resource can reference one declared below it and file ordering is irrelevant.

2 · Resolve values

Variables are resolved from their sources in precedence order, locals are evaluated, and provider configurations are built. Data sources whose arguments are fully known are read now — which is why a data source can be used to compute something the rest of the plan depends on.

Data sources whose arguments depend on a resource that doesn't exist yet can't be read now. They become unknown, are deferred to apply, and are rendered <= — read — in the plan.

3 · Refresh

For every managed resource instance in state, Terraform asks the provider for its current attributes. This is where drift is discovered, and it costs at least one API call per resource instance, which is why plan time scales with state size and why very large states hit provider rate limits (Scale & Performance).

Three outcomes matter:

  • The object exists and matches state → state is updated with any newly-returned attributes.
  • The object exists and differs → the plan will show the difference, and modern Terraform reports it separately under a heading noting that objects have changed outside of Terraform. ⚠️ verify exact wording.
  • The object is gone → Terraform marks it as needing creation. Deleted out of band.

Refresh results are persisted to state, which is why plan — read-only with respect to infrastructure — still writes.

4 · Build the graph

Terraform builds a directed acyclic graph whose nodes are resource instances, data sources, providers, variables, locals and outputs, with edges expressing "this must happen before that". Edges come from references, depends_on, module boundaries and provider relationships. That's the whole of The Dependency Graph and it's the next page for a reason: ordering surprises are graph questions, not plan questions.

5 · Diff

Walking the graph, Terraform compares desired configuration against refreshed state for each resource instance and selects an action. The decision procedure, in effect:

Comparison of an in-place update keeping the object and its identity against a replacement destroying it and creating a new one

Situation Action
In configuration, not in state create
In state, not in configuration destroy
In both, no attribute differs no-op
In both, differing attributes are all updatable update in place
In both, and any differing attribute requires a new resource replace
Data source, arguments known read now
Data source, arguments unknown read deferred to apply

The fifth row is the one to internalise. Replacement is decided per attribute, not per resource, and one attribute is enough. Which attributes force it is a property of the provider's schema — each attribute is flagged as updatable or as requiring replacement, reflecting what the underlying cloud API can actually change on a live object. You cannot infer it from HCL; you read it in the provider documentation, or you see it in the plan.

6 · Render

The diff is turned into the output you read. The symbols:

Symbol Meaning What to think
+ create New resource. Check it's one you meant to add
- destroy Something goes away. Every one needs an explanation
~ update in place Usually safe. The object keeps its identity
-/+ replace: destroy then create Data loss risk. Look for # forces replacement
+/- replace: create then destroy Same replacement, safer ordering — create_before_destroy is set
<= read (data source) Deferred until apply, because its arguments are unknown
(known after apply) unknown value Terraform can't compute this yet

Two annotations in the body matter as much as the symbols. # forces replacement appears against the specific attribute that escalated an update into a replacement — that's the line that tells you why, and it's the first thing to look for on any -/+. And # (5 unchanged attributes hidden) means the output was truncated for readability; -concise and its inverse control this, ⚠️ verify current flag names.

7 · Apply

apply re-runs phases one to six unless given a saved plan file, then walks the graph executing actions. Independent resources proceed concurrently — ten at a time by default, ⚠️ verify — and state is written incrementally as each resource completes, not in one commit at the end. That is precisely why a failed apply leaves accurate state describing a half-built world, and why re-running apply finishes the job.

Applying a saved plan skips one to six and executes exactly what's recorded. If reality has moved such that the plan's assumptions are false, it errors rather than adapting.

Unknown values, and why plans get vague

This is the mechanism behind most "why is my plan showing that?" questions.

Diagram of an unknown value propagating from one resource to every downstream value derived from it

resource "aws_s3_bucket" "logs" {
  bucket = "my-logs-bucket"
}

resource "aws_s3_bucket" "app" {
  bucket = "my-app-bucket"

  tags = {
    # depends on an attribute of a resource that doesn't exist yet
    log_target = aws_s3_bucket.logs.arn
  }
}

At plan time the logs bucket doesn't exist, so its ARN is unknown, so the tag value is unknown, so:

# UNVERIFIED — confirm against a real run
  # aws_s3_bucket.app will be created
  + resource "aws_s3_bucket" "app" {
      + bucket = "my-app-bucket"
      + tags   = {
          + "log_target" = (known after apply)
        }
    }

Harmless here. It stops being harmless when an unknown feeds an attribute that forces replacement, because Terraform must then assume the attribute might change and propose replacing an existing resource on that basis. The general shape of the fix: don't derive identity-bearing or replacement-forcing attributes from other resources' computed attributes. Derive them from variables, locals and data sources — things knowable at plan time.

Identical in azurerm and google; the propagation rule is Terraform's, not a provider's.

What forces replacement — this is where providers genuinely differ

The rule is Terraform's; the answers are the cloud's. Renaming an object storage bucket forces replacement everywhere, because in all three clouds the name is the object's identity. But the surrounding attributes diverge, and the divergence is worth knowing before you propose a change.

```hcl
resource "aws_s3_bucket" "demo" {
  bucket = "tf-lifecycle-demo-0725" # change → forces replacement
  tags   = { Environment = "dev" }  # change → in-place update
}
```
Region is set on the provider, not the resource — so moving a bucket between regions
is not an in-place change and not a replacement either; it's a different provider
configuration, and Terraform will not migrate the data.
```hcl
resource "azurerm_storage_account" "demo" {
  name                     = "tflifecycledemo0725" # change → forces replacement
  resource_group_name      = "tf-demo"             # change → forces replacement
  location                 = "westeurope"          # change → forces replacement
  account_tier             = "Standard"            # ⚠️ verify: tier changes may force replacement
  account_replication_type = "LRS"                 # in-place update for some transitions only
  tags                     = { Environment = "dev" } # in-place update
}
```
Azure has the largest replacement surface of the three, because the resource group
and location are part of the resource's identity rather than provider-level settings.
Moving a storage account between resource groups is a replacement.
```hcl
resource "google_storage_bucket" "demo" {
  name          = "tf-lifecycle-demo-0725" # change → forces replacement
  location      = "EUROPE-WEST1"           # change → forces replacement
  storage_class = "STANDARD"               # in-place update
  labels        = { environment = "dev" }  # in-place update — labels, not tags
}
```
Location is a resource attribute here, not a provider setting, so a region change
is a visible replacement in the plan rather than something that quietly does nothing.

The pattern generalises: whatever the cloud treats as part of an object's identity or physical placement forces replacement. Azure's hierarchy means more of that is expressed on the resource, so Azure plans show replacements where AWS plans show nothing at all — which is arguably safer, because a silent no-op is worse than a visible danger. The canonical comparison of scoping and metadata across the three is in Providers & the Registry.


Getting Started

The goal is to produce each symbol deliberately and watch the output. One bucket, local state. Shown in AWS; identical in azurerm and google apart from the naming constraints noted above.

# main.tf
terraform {
  required_version = ">= 1.5"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}

provider "aws" {
  region = "eu-west-1"
}

variable "environment" {
  type    = string
  default = "dev"
}

resource "aws_s3_bucket" "demo" {
  bucket = "tf-lifecycle-demo-0725"

  tags = {
    Environment = var.environment
  }
}

+ — create

terraform init && terraform apply

~ — update in place

Change the variable, not the bucket name:

terraform plan -var environment=staging
# UNVERIFIED — confirm against a real run
  # aws_s3_bucket.demo will be updated in-place
  ~ resource "aws_s3_bucket" "demo" {
        id     = "tf-lifecycle-demo-0725"
      ~ tags   = {
          ~ "Environment" = "dev" -> "staging"
        }
        # (9 unchanged attributes hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

The id line has no symbol — unchanged, shown for context. Apply it.

-/+ — replacement

Now change the bucket name in main.tf to tf-lifecycle-demo-0725-renamed:

terraform plan
# UNVERIFIED — confirm against a real run
  # aws_s3_bucket.demo must be replaced
-/+ resource "aws_s3_bucket" "demo" {
      ~ arn    = "arn:aws:s3:::tf-lifecycle-demo-0725" -> (known after apply)
      ~ bucket = "tf-lifecycle-demo-0725" -> "tf-lifecycle-demo-0725-renamed" # forces replacement
      ~ id     = "tf-lifecycle-demo-0725" -> (known after apply)
        tags   = { "Environment" = "staging" }
    }

Plan: 1 to add, 0 to change, 1 to destroy.

Read this output properly, because it's the whole page in eight lines. must be replaced and -/+. The # forces replacement comment naming the exact culprit. arn and id becoming (known after apply), because the new object doesn't exist yet and its identifiers can't be known. And a summary line reading 1 to add ... 1 to destroy for what looked like a rename.

Do not apply this. Revert the name instead — the point was the plan.

- — destroy, and reading a saved plan

terraform plan -out=tfplan
terraform show tfplan          # the same plan, rendered from the file
terraform show -json tfplan | jq '.resource_changes[].change.actions'
# UNVERIFIED — confirm against a real run
[
  "delete",
  "create"
]

That JSON is what policy tools consume — a machine can assert "no delete actions on resources tagged production" far more reliably than it can parse terminal output.

-refresh-only — see drift without a diff

Add a tag to the bucket by hand in the console, then:

terraform plan -refresh-only
# UNVERIFIED — confirm against a real run
Note: Objects have changed outside of Terraform

Terraform detected the following changes made outside of Terraform since the
last "terraform apply" which may have affected this plan:

  # aws_s3_bucket.demo has been changed
  ~ resource "aws_s3_bucket" "demo" {
      ~ tags = {
          + "ManualChange" = "true"
        }
    }

This is drift detection in one command, and the basis of the scheduled jobs in Drift & Reconciliation.

terraform destroy

In Practice

Save the plan, review the saved plan, apply the saved plan. The production form of this lifecycle is three steps that guarantee the approved artefact and the executed artefact are the same object:

terraform plan -out=tfplan -input=false -lock-timeout=5m
terraform show tfplan                            # into the pull request comment
terraform show -json tfplan > plan.json          # into policy and cost checks
terraform apply -input=false tfplan              # after approval

-input=false makes Terraform fail rather than hang waiting for a prompt, which is what you want in CI. -lock-timeout waits for a contended lock instead of failing instantly.

Gate on the JSON, not on human attention. terraform show -json gives every proposed change as structured data, and the check worth writing first is fail the build on any unexpected delete. Humans skim; a policy check doesn't. This is the integration point for Conftest, OPA, Sentinel and Infracost — see Governance & Policy as Code.

Treat plan artefacts as secrets. A saved plan contains resource attributes in plaintext, including sensitive ones. Short retention, restricted access, never a public CI artefact. Secrets & Sensitive Data.

Write configuration that produces legible plans. This is a real design consideration and it's rarely stated. Deriving replacement-forcing attributes — names, identifiers, placement — from other resources' computed attributes produces plans full of (known after apply) where you needed certainty. Derive them from variables and locals instead:

locals {
  name_prefix = "${var.project}-${var.environment}"   # known at plan time
}

resource "aws_s3_bucket" "app" {
  bucket = "${local.name_prefix}-app"                 # so the plan shows the real name
}

What a reviewer should look for in a diff touching this topic — and in the plan attached to it:

  • The summary line matches the PR description. "Adds a tag" and 1 to destroy is a conversation.
  • Every - and every -/+ is explained in the PR body. Not in a comment thread. In the body.
  • Each -/+ has been traced to its # forces replacement line, and the author says which attribute it was. If they can't, they haven't read the plan.
  • A replacement on anything data-bearing is a stop. Database, bucket with objects, disk. The correct next step is a migration runbook, not an apply.
  • (known after apply) on something that ought to be knowable — usually a naming expression that should have come from a local rather than another resource.
  • Was -refresh=false used? Then the plan cannot see drift and isn't an approval basis.
  • Is the plan attached at all, and is it the plan that will run? A plan from a laptop and an apply in CI are two different plans.

Rollback and blast radius. The lifecycle's protection is entirely the review step; after that, Terraform executes as fast as the graph allows. Reverting the commit and re-applying restores the described state but not data — a replaced database comes back empty. The mechanisms that actually limit damage sit outside this page: prevent_destroy on things that must not die (Meta-Arguments), and splitting state so no single apply can reach everything (Repo & Environment Structure).


Ecosystem

terraform show -json and the plan JSON schema. The documented, versioned representation of a plan. Everything below consumes it, and knowing it exists changes how you automate: you never parse terminal output. The glue is plan -out followed by show -json.

Conftest / OPA and Sentinel. Policy engines that assert rules over the plan JSON — no public buckets, no untagged resources, no deletes outside a change window. The glue: a CI step between plan and approval that exits non-zero. Governance & Policy as Code.

Infracost. Reads plan JSON and estimates the cost delta of the change, posted as a PR comment next to the plan. The glue is the same JSON file. It turns "this adds a NAT gateway" into a number, which changes review conversations more than you'd expect.

Atlantis and HCP Terraform. Both exist largely to enforce the discipline this page argues for: plan on pull request, plan output visible where the review happens, apply gated on approval, applying the saved plan rather than a fresh one. CI/CD & Automation.

TF_LOG=DEBUG / TF_LOG=TRACE. When a plan proposes something inexplicable, the debug log shows the provider API calls made during refresh and the values returned — which is how you find out that a provider is normalising an attribute and reporting a permanent difference. Enormous output; use TF_LOG_PATH.

Provider behaviour, across the three clouds. The lifecycle is identical. What varies is refresh cost (a function of API latency and rate limits, so plan times differ for identical resource counts) and replacement surface, per the tab set above — Azure's resource-group and location attributes put more of the object's identity on the resource, so Azure plans show replacements where AWS shows nothing.


Production

Security

The plan pipeline reads everything it manages, so the credentials it uses can read every attribute of every resource — and both the plan file and the refreshed state contain those attributes in plaintext. Two practical consequences: plan artefacts need the same handling as state files, and plan permissions can be smaller than apply permissions — read plus state write, rather than create-and-destroy — which is worth separating in CI where you can. The subtler risk is a plan rendered into a public pull request on an open-source repository, exposing account IDs, network topology and occasionally a secret.

Blast radius

Replacement is the hazard, not destroy. destroy announces itself; -/+ reads like a change and is a delete plus a create. The habits that catch it: read the summary line first, then search the plan body for forces replacement before anything else, and never approve a replacement on a data-bearing resource without a migration plan. The structural mitigations are prevent_destroy and smaller state files. One more: -target produces a plan over part of the graph, so its output is not evidence about the whole configuration — a targeted plan that looks clean tells you nothing about what a full apply would do.

Scale

Refresh dominates. At least one API call per resource instance, so plan time grows roughly linearly with state size, bounded by provider rate limits — and at a few thousand resources this is minutes, not seconds. The two tuning options both have a cost: -refresh=false is fast and blind to drift; -parallelism=N helps until the provider begins throttling, at which point it makes things worse. The real answer at that scale is fewer resources per state file, which is Scale & Performance.

Team workflow

Concurrent runs contend for the state lock, and with a locking backend the second run fails clearly rather than corrupting anything — use -lock-timeout in CI so a queued job waits instead of failing. The workflow rule that matters: the plan a human approved is the plan that runs, which means -out and apply FILE, every time. Without it, review is theatre — a colleague approved a diff of .tf files, and a provider upgrade in the same merge can produce replacements the code diff never hinted at.

Reliability

apply writes state incrementally and is idempotent, so the normal recovery from a failed apply is to fix the cause and run it again; it creates only what's missing. The failure modes that need more than that are narrow but real: a resource created in the cloud whose API response was lost, so it exists unrecorded and the next plan proposes creating a duplicate; and a lock left behind by a killed process. Both are Failure & Recovery.


Interview Questions

Conceptual

Walk me through what happens when you run `terraform plan`.

Parse and validate the configuration into a syntax tree, checking internal consistency and types against provider schemas. Resolve values — variables in precedence order, locals, provider configurations, and any data sources whose arguments are already known. Refresh: read the current attributes of every managed resource from the provider's API, discovering drift. Build the dependency graph. Diff each resource instance, choosing create, read, update, replace, destroy or no-op. Render the result.

apply runs the same six phases and then walks the graph executing actions, writing state incrementally — unless it's given a saved plan file, in which case it skips straight to execution.

Why can a plan show changes when nobody changed any code?

Because a plan diffs configuration against reality, not against the previous commit. Four common causes: drift, where someone changed a resource by hand or another system did; a provider version upgrade changing defaults or adding attributes; a resource deleted out of band, which appears as a create; and unstable expressions in the configuration itself — a timestamp() call or a value the provider normalises differently to how you wrote it, producing a permanent diff. That last category is a configuration bug, and the others aren't.

What do `+`, `-`, `~`, `-/+`, `+/-` and `<=` mean?

+ create, - destroy, ~ update in place, -/+ replace by destroying then creating, +/- replace by creating then destroying — that ordering means create_before_destroy is set — and <= read a data source, deferred to apply because its arguments aren't known yet. (known after apply) isn't an action; it's an unknown value.

The distinction that matters is ~ versus -/+: one keeps the object and its data, the other destroys it.

Is a plan a guarantee of what apply will do?

No. It's a prediction computed from state and reality at a moment in time, and nothing is reserved or locked in the cloud account. Between plan and apply, someone can change a resource by hand, another pipeline can apply, or a quota can be exhausted. A bare apply mitigates this by re-planning and showing you the fresh plan; a saved plan file mitigates it differently, by failing if its assumptions no longer hold rather than adapting silently.

What does `(known after apply)` mean, and when should it worry you?

The value is computed by the provider and can't be known before the object exists — an ARN, a generated ID, an IP address. Ordinarily it's just how plans look. It should worry you in two cases: when it appears against an attribute that forces replacement, because Terraform must then assume replacement might be necessary and can propose destroying a live resource on that basis; and when it appears where you needed certainty to approve the change — an unknown bucket name means you can't verify the plan does what the PR claims.

Technical depth

What decides whether a change is an in-place update or a replacement?

The provider's schema. Each attribute is marked according to whether the underlying API can change it on a live object; changing an attribute flagged as requiring a new resource escalates the whole resource to replacement. It's decided per attribute, and one is enough — the rest of the diff being updatable makes no difference. You can't infer it from HCL; you read the provider documentation or you look at the plan, where the specific attribute is annotated # forces replacement.

Generally, whatever the cloud treats as an object's identity or physical placement is immutable: names, regions, and on Azure the resource group.

Explain unknown value propagation and why it causes surprising plans.

Any expression containing an unknown value is itself unknown, transitively. So one computed attribute of a not-yet-created resource makes every downstream value derived from it unknown, however far away. This is normally cosmetic, but it becomes serious when an unknown lands on a replacement-forcing attribute: Terraform can't prove the value won't change, so it must plan for replacement, and you get a proposed destroy of a healthy resource caused by an unrelated new one.

The mitigation is a configuration style choice — derive names, identifiers and placement from variables, locals and data sources, all resolvable at plan time, rather than from other resources' computed attributes.

What's the difference between `-refresh=false` and `-refresh-only`?

Nearly opposites. -refresh=false skips the refresh phase and diffs configuration against recorded state — fast, useful when iterating on a large configuration, and structurally blind to drift, so it must never be the basis of a production approval. -refresh-only does only the refresh and reports what changed outside Terraform without proposing any configuration-driven changes; it's the drift detection command, and it can update state to match reality without touching infrastructure.

Why does `terraform plan` write to state?

Because refresh is part of planning, and the attributes it reads are persisted so subsequent operations don't re-read them unnecessarily. So "plan is read-only" is a claim about infrastructure, not about state — a plan also acquires the state lock, which is why two people planning concurrently against a shared backend can see a lock error. -refresh=false avoids the writes, at the cost of being unable to see drift.

What is a stale plan, and why does applying one fail rather than adapting?

A saved plan file records the actions to take and enough about the state it was computed from to know whether that state still holds. If state has moved on — someone else applied, or drift changed a resource — the assumptions are false, and Terraform errors rather than recomputing. That's the intended behaviour: the entire value of a saved plan is that the approved artefact and the executed artefact are identical, and silently doing something different would destroy it. The correct response is to re-plan, re-review and re-approve.

How does this differ across AWS, Azure and GCP?

The pipeline itself doesn't differ at all — six phases, same symbols, same diff rules, because it's Terraform's logic and not the provider's. Saying that first is part of a good answer.

Three real differences. Replacement surface: Azure puts more of a resource's identity on the resource itself — resource_group_name and location are attributes, and changing either forces replacement — whereas on AWS the region lives on the provider, so a region change isn't visible in the plan as a replacement at all. GCP sits in between, with location on the bucket. Azure therefore shows replacements where AWS shows nothing, which is arguably safer.

Refresh cost: identical resource counts produce different plan times, because API latency and rate limits differ, and that determines when you start splitting state.

Attribute normalisation: providers differ in how aggressively they rewrite what you wrote — JSON policy documents on AWS being the classic source of permanent diffs — so the "plan shows a change every time and applying it changes nothing" problem is more common on some providers than others.

Scenario and design

A plan proposes replacing a production database you only meant to re-tag. Diagnose it.

Find the # forces replacement annotation in the plan body — that names the attribute, and everything follows from it. The likely causes: the resource address was renamed, so Terraform sees a destroy and a create of two different resources rather than one rename; an identity attribute changed, perhaps through a modified naming expression; a provider upgrade changed a default or started tracking an attribute it previously ignored; or an unknown value from another resource has landed on a replacement-forcing attribute.

The fixes differ completely. A rename is a moved block, and produces no infrastructure change at all (Import & Refactoring). A provider-upgrade artefact may need ignore_changes or a corrected configuration. A genuine required replacement is a data migration with a runbook, not a Terraform change — and prevent_destroy should have stopped this reaching a plan review.

Design the plan/apply stage of a CI pipeline for production infrastructure.

On pull request: init with a backend the pipeline authenticates to via OIDC rather than long-lived keys, then validate, fmt -check and tflint, then plan -out=tfplan -input=false -lock-timeout=5m. Publish show tfplan as a PR comment so the review happens next to the proposed change, and show -json tfplan into policy checks — failing the build on unexpected deletes, untagged resources, or a cost delta over a threshold. Store the plan file as a restricted artefact, because it contains secrets.

On merge: apply -input=false tfplan against that artefact, behind an environment protection rule requiring a named approver. Not a fresh plan — the point is that the reviewed and executed artefacts are the same.

Also: a scheduled plan -detailed-exitcode against production to alert on drift, and concurrency control so two applies can't race for the lock. See CI/CD & Automation.

Every plan shows the same change, and applying it never makes it go away. What's happening?

A permanent diff, and there are four usual causes. The provider normalises the value — reordering JSON keys, lowercasing, adding a default — so what it reads back never equals what you wrote; the fix is to write it in the provider's canonical form, often via a helper data source. Another system is modifying the resource between applies, so it's genuine drift on a loop. The configuration contains a value that changes every evaluation, such as timestamp(). Or there's a provider bug where an attribute is written but not read back correctly.

Diagnosis: TF_LOG=DEBUG to see what the API actually returned during refresh, compared with what you wrote. Resolution in order of preference: fix the configuration to match canonical form; find who else is writing to the resource; and only as a last resort ignore_changes on that attribute, which is a deliberate concession that something else owns it and not a way to silence noise (Drift & Reconciliation).

Your plans take twenty minutes. What do you investigate, and in what order?

First establish that it's refresh, which it almost always is — compare plan against plan -refresh=false. If the gap is the whole twenty minutes, the cost is API calls, and it's proportional to resource count and provider latency.

Then, in order: how many resource instances are in this state, and are any of them large for_each expansions of resources that could be one resource; is the provider rate-limiting, visible in debug logs as retries; would -parallelism above the default help, or is throttling already the constraint; and are there data sources being re-read expensively on every plan.

But the honest answer is that tuning flags buys a little and splitting state buys a lot. Twenty-minute plans mean one state file owns too much, and the fix is seams placed where change frequency and blast radius differ — foundational networking apart from fast-moving application resources, environments always separate. See Scale & Performance.


Commands & Gotchas

terraform plan                                # the six phases, rendered
terraform plan -out=tfplan                    # ...frozen into a file
terraform plan -refresh-only                  # drift only: what changed outside Terraform
terraform plan -refresh=false                 # skip refresh — fast, and blind to drift
terraform plan -detailed-exitcode             # 0 no changes, 1 error, 2 changes pending
terraform plan -var environment=staging       # override a variable for this run
terraform show tfplan                         # render a saved plan for a human
terraform show -json tfplan                   # ...for a policy engine or cost tool
terraform apply tfplan                        # execute exactly that plan, or fail
terraform apply -input=false -lock-timeout=5m # CI form: never prompt, wait for the lock
terraform apply -replace=aws_s3_bucket.demo   # force replacement of one resource deliberately
TF_LOG=DEBUG TF_LOG_PATH=tf.log terraform plan # what the provider actually returned
Behaviour Why it matters
A plan diffs config against reality, not against your last commit An empty code diff can still produce a large plan
Refresh is one API call per resource instance Plan time scales with state size; this is the number that forces state splits
plan writes state and takes the lock "Read-only" refers to infrastructure only
Replacement is decided per attribute One immutable attribute escalates the whole resource. Search for forces replacement
-/+ destroys first; +/- creates first The second means create_before_destroy is set
Unknown values propagate transitively An unknown on a replacement-forcing attribute can propose destroying a healthy resource
Values from variables/locals/data sources are known at plan time Derive names from those, never from another resource's computed attributes
apply writes state incrementally, not atomically So a failed apply leaves accurate state, and re-running finishes the job
apply FILE fails rather than adapting when state moved That failure is the feature that makes approval meaningful
-refresh=false cannot see drift Never an approval basis for production
A -targeted plan is evidence about part of the graph only A clean targeted plan says nothing about a full apply
Plan files contain sensitive values in plaintext Same handling as a state file

← Back to The Machinery · Next: The Dependency Graph →


⚠️ Verification checklist (delete before publishing)

Plan output — every UNVERIFIED block needs a real capture, and these are the most load-bearing outputs in the article

  • ~ in-place tag update: exact rendering, including whether unchanged attributes appear without a symbol and the # (N unchanged attributes hidden) wording and count.
  • -/+ replacement on a bucket rename: confirm the header reads must be replaced, the symbol placement, the position of # forces replacement, and which attributes become (known after apply). The whole page rests on this block being accurate.
  • The unknown-propagation example — confirm a tag derived from another bucket's ARN really renders as (known after apply) nested inside the tags map as shown.
  • plan -refresh-only output, especially the exact heading. I wrote "Objects have changed outside of Terraform" and flagged it inline; get the real string.
  • show -json | jq '.resource_changes[].change.actions' output for a replacement — confirm it's ["delete","create"] and not ["create","delete"] or a single replace action.
  • Confirm +/- (create-then-destroy) renders as I've described when create_before_destroy is set. Not demonstrated on the page — either capture it or soften the claim.

Behavioural claims — several are asserted confidently and used in interview answers

  • Default parallelism is 10. Flagged inline. ⚠️ verify.
  • That state is written incrementally during apply rather than once at the end. This claim appears three times and underpins the failure-recovery argument.
  • That plan persists refresh results to state by default in current versions — same item as 00-orientation/02-core-workflow.md, verify once and fix both pages.
  • That data sources with known arguments are read during plan, and those with unknown arguments are deferred and rendered <=. ⚠️ verify the symbol is still <=.
  • -concise flag name and its inverse for controlling hidden attributes. Flagged inline — ⚠️ verify this flag exists at all; I may be misremembering it.
  • That apply -replace=ADDR is the current spelling (it replaced terraform taint).
  • That a stale saved plan produces an error rather than a re-plan, and capture the error text.
  • Whether validate genuinely type-checks against provider schemas, or only checks syntax and references. Asserted in phase 1 and in an interview answer.

Provider-specific claims in the replacement tab set — this block is the most likely on the page to be wrong

  • AWS: bucket forces replacement; tags in-place. Confirm.
  • AWS: the claim that changing the provider region does not produce a replacement and does not migrate data. ⚠️ verify — this is a strong claim about a silent behaviour.
  • Azure: that name, resource_group_name and location all force replacement on azurerm_storage_account.
  • Azure: account_tier — flagged inline as uncertain. Either verify or delete the line rather than shipping a hedge.
  • Azure: account_replication_type — "in-place for some transitions only" is vague. Get the real rule or cut the comment.
  • GCP: that name and location force replacement and storage_class is in-place.
  • The generalisation that Azure has "the largest replacement surface of the three" — defensible, but confirm it survives contact with the real schemas.

Versions and syntax

  • Provider version constraint ~> 5.0 for aws — stale, same as the Stage 0 pages. Fix all four pages in one pass.
  • required_version = ">= 1.5" — confirm nothing here needs higher.
  • -input=false, -lock-timeout, -detailed-exitcode, -parallelism flag names.

Rendering and structure

  • The single <Tabs> block renders. Note this page deliberately uses one tab set, for the replacement-surface comparison only, with *Identical in azurerm and google* notes elsewhere — confirm that reads as intended and doesn't look like an omission.
  • Length check: this is the longest page so far. If it exceeds the 500-line target when rendered, the candidate for extraction is the unknown-values material, which could become its own topic within this stage. Don't let it grow further without splitting.
  • All relative links resolve; note that ../01-language/* targets don't exist yet.