What a plan is
terraform plan compares three things:
- The HCL, what you want.
- The state, what you thought was in the cloud.
- 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:
| Mark | Meaning | When |
|---|---|---|
+ | Create | The resource is in HCL but not in state |
~ | Update in place | An 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 |
- | Destroy | The 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 containsforces 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_changesthat 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
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
arnis known only once the bucket exists. -
-refresh=falseis 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-colorif your CI logs do not parse ANSI.