linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Introduction
  • Lessons
  • How it works
  • Knowledge base
  • Cheat sheet
  • Capstone
  • Interview prep
home/terraform/kb/Debugging/tf-plan-diff

kb/debugging ── Debugging ── beginner

How to read a terraform plan: symbols, formatting, traps

A plan shows a diff: `+` create, `~` update in place, `-/+` replace, `-` destroy, `<=` data read. The summary line at the bottom reads `Plan: X to add, Y to change, Z to destroy`. The rule that matters: a second plan after apply should be clean ("No changes").

view as markdownaka: terraform-plan-diff, reading-plan

What a plan is

terraform plan compares three things:

  1. The HCL, what you want.
  2. The state, what you thought was in the cloud.
  3. Reality, what is in the cloud right now (learned through refresh).

The output is a diff. It is the plan of which API calls the next apply will make.

A plan changes nothing. It is safe, so run it as often as you like.

The symbols in front of a resource

To the left of each resource in a plan you get one of four marks:

MarkMeaningWhen
+CreateThe resource is in HCL but not in state
~Update in placeAn attribute changed that the provider can change without replacing the resource
-/+Replace (destroy + create)An attribute changed that cannot be changed without replacing the resource
-DestroyThe resource is in state but not in HCL
<=Read (data)A data block that reads something from the cloud

The final line:

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

This is a tally by symbol: 2 resources with +, 1 with ~, 0 with - or -/+.

Example: creation

  # aws_s3_bucket.demo will be created
  + resource "aws_s3_bucket" "demo" {
      + bucket    = "linuxlab-hello-abc123"
      + id        = (known after apply)
      + arn       = (known after apply)
      + tags      = {
          + "Owner" = "student"
        }
    }
Plan: 1 to add, 0 to change, 0 to destroy.

Notice:

  • + bucket = "linuxlab-hello-abc123", this is what you set in HCL.
  • + id = (known after apply), the cloud will compute it on creation.
  • + arn = (known after apply), the same.

(known after apply) is normal. It applies to any computed attribute.

Example: update in place

  # aws_s3_bucket.demo will be updated in-place
  ~ resource "aws_s3_bucket" "demo" {
        id = "linuxlab-hello-abc123"
      ~ tags = {
            "Owner" = "student"
          + "Environment" = "learning"
        }
    }
Plan: 0 to add, 1 to change, 0 to destroy.

Here:

  • ~ to the left of the resource means update-in-place. The objects inside the bucket survive, only the metadata changes.
  • ~ tags, changes inside the tags block.
  • + "Environment", a new tag.
  • "Owner" = "student" has no mark, it does not change, and is shown for context.

Example: replacement (-/+)

  # aws_s3_bucket.demo must be replaced
  -/+ resource "aws_s3_bucket" "demo" {
      ~ id            = "linuxlab-old-abc123" -> (known after apply)
      ~ bucket        = "linuxlab-old-abc123" -> "linuxlab-new-xyz789" # forces replacement
        tags          = {
            "Owner" = "student"
        }
    }
Plan: 1 to add, 0 to change, 1 to destroy.

The phrase to watch for is # forces replacement to the right of the line. The bucket attribute cannot be changed on an existing bucket (AWS does not support renaming). So Terraform tears down the old one and creates a new one.

This is dangerous: everything in the bucket will be lost. Watch for forces replacement when you review plans.

To change the order (create the new one first, then tear down the old one, with no downtime): use lifecycle.create_before_destroy = true. See tf-resource-lifecycle.

Example: deletion

  # aws_s3_bucket.demo will be destroyed
  - resource "aws_s3_bucket" "demo" {
      - id        = "linuxlab-old-abc123" -> null
      - bucket    = "linuxlab-old-abc123" -> null
      - arn       = "arn:aws:s3:::linuxlab-old-abc123" -> null
      - tags      = {
          - "Owner" = "student" -> null
        } -> null
    }
Plan: 0 to add, 0 to change, 1 to destroy.

Every attribute goes to null, which means "all of this is leaving". The resource is gone from HCL.

If you did not want this, do not apply. Restore the resource in HCL. After apply it is deleted, and you cannot get it back.

Example: reading data

  # data.aws_caller_identity.current will be read during apply
  # (config refers to values not yet known)
  <= data "aws_caller_identity" "current" {
      + account_id = (known after apply)
      + arn        = (known after apply)
      + id         = (known after apply)
      + user_id    = (known after apply)
    }

<= means "I will read this during apply". Data does not change state, it just reads values. It does not show up in the "Plan: X to add" tally.

Sensitive values

  + password = (sensitive value)

An attribute marked sensitive is masked in the output. In the state file it sits in plain text. See tf-state on protecting state.

The plan summary, what to look for

Plan: 2 to add, 1 to change, 3 to destroy.

Raise an eyebrow when:

  • "to destroy" is greater than 0 in production. Especially a database or an S3 bucket with data in it. Check: is this on purpose?
  • "to change", but a ~ block contains forces replacement. Then it is really a destroy + create, and it counts toward "to destroy". Read each change.
  • "No changes" when you did edit the HCL. That means your edits never reached the file (forgot to save?), or you are applying with the wrong var.X (the wrong variable).
  • "Plan: 0 to add, 0 to change, 0 to destroy", yet you see warnings. Sometimes the provider was upgraded and something new showed up. Read it again.

The main invariant

After apply, a second plan = "No changes".

If it is not, something has diverged:

  • Drift in the cloud (someone changed it by hand).
  • An attribute under ignore_changes that Terraform still considers different.
  • A provider bug (see "inconsistent final plan" in tf-common-errors).

In CI, use terraform plan -detailed-exitcode:

  • exit 0 = clean (No changes).
  • exit 1 = error.
  • exit 2 = there are changes.

This lets you alert when drift shows up without an apply.

-out, save the plan to a file

bash
terraform plan -out=plan.bin
terraform apply plan.bin

Apply will run exactly this plan. If something changed in the meantime, it fails with "saved plan is stale" (see tf-common-errors).

This is the pattern for production:

  • The plan is generated in one CI job.
  • The plan file is uploaded as an artifact.
  • Apply, a separate job, reads the artifact.

That guarantees what is applied is exactly what was approved.

Traps

  • A plan can shift between runs. If refresh found drift, the next plan shows something different. That is normal.

  • "Known after apply" does not mean a bug. It means "the value will appear after the resource is created". For example, a bucket's arn is known only once the bucket exists.

  • -refresh=false is faster but risky. A plan without refresh uses the state as is, without checking against the cloud. It is fast, but it can miss drift. For production operations you want refresh.

  • JSON output (-json): for CI, not for humans. The JSON structure is stable across TF minor versions and convenient for tooling (terraform-summarize, tf-summarize, Atlantis).

  • Colors hurt the eyes in CI logs. Use -no-color if your CI logs do not parse ANSI.

§ команды

bash
terraform plan

The standard plan. A diff between HCL and state, after refresh.

bash
terraform plan -detailed-exitcode

Exit 0 = clean, 1 = error, 2 = there are changes. For CI drift alerts.

bash
terraform plan -out=plan.bin

Save the plan to a file. Apply it with `terraform apply plan.bin`.

bash
terraform show plan.bin

Print the contents of a saved plan in human-readable form.

bash
terraform show -json plan.bin | jq '.resource_changes[].address'

List the addresses of every changed resource from a plan file. For tooling and reports.

bash
terraform plan -refresh=false

A plan without reaching out to the cloud. Faster, but it does not see drift. Use with care.

§ см. также

  • tf-planterraform plan: see what Terraform is about to doplan is a dry run: Terraform reads your HCL, reads the state, and shows the diff between them. It changes nothing in the cloud. This is your main tool for not breaking prod by mistake.
  • tf-applyterraform apply: apply a plan to a real cloudapply takes the result of plan and actually calls the cloud API: it creates, changes, and deletes resources. After apply, the state is updated. This is the command that turns money into infrastructure.
  • tf-resource-lifecyclelifecycle: controlling resource behaviorThe lifecycle block configures four behaviors: create_before_destroy (zero-downtime replacement), prevent_destroy (deletion guard), ignore_changes (ignore drift on specific attributes), replace_triggered_by (force replacement on an external signal).
  • tf-replace-target-replace and -target: targeted operations on a single resourceThe `-replace=<address>` and `-target=<address>` flags restrict apply to a single resource. `-replace` recreates the resource and replaces the deprecated `terraform taint`. `-target` applies only to the specified resource; it is an emergency tool, not an everyday one.
Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies