TerraformXVI · Plan Review and Saved PlansProduction Terraform
Plan Symbols: Create, Update, Destroy, Replace
What you'll learn
- Identify every prefix that can appear in a Terraform plan and explain what each means mechanically
- Distinguish an in-place update (~) from a destroy-then-create (-/+) and a create-then-destroy (+/-) by reading the action tuple
- Recognise (known after apply) and decide whether downstream resources can depend on a deferred value
- Predict the data-loss characteristics of a forced replacement before approving the plan
- Explain what the <= read symbol means and where Terraform actually reports drift found during refresh
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
A Terraform plan is a list of resource addresses with a short prefix attached to each one. The prefixes are computed from the action tuples Terraform produces when it walks the resource graph: create, read, update, delete, and the no-op. Terraform prints a legend above the resource list listing the symbols that appear in that particular plan. The complete set is:
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
~ update in-place
- destroy
-/+ destroy and then create replacement
+/- create replacement and then destroy
<= read (data resources)
A plan with no replacements omits the two replacement lines,
so the absence of -/+ from the legend is itself information.
Most production incidents that started with terraform apply
started with an engineer who did not read a line in the plan.
The line that ended the engineer’s week began with one of the
prefixes on this page. Read the whole plan. Read the
prefixes in order. Read the resource addresses. Read the
attribute diffs.
The prefixes
Read every prefix left to right. Each one tells you the shape of the action Terraform will take against the cloud when the apply runs.
+ create
+ means Terraform will create a brand-new resource in the
provider. The resource does not exist in the state; the apply
will issue the appropriate create call.
+ resource "aws_instance" "web" {
+ id = (known after apply)
+ ami = "ami-0c7217cdde317cfec"
+ instance_type = "t3.small"
+ subnet_id = "subnet-0abc123"
+ tags = {
+ "Name" = "web-prod-01"
}
}
The apply will send a RunInstances call (or the equivalent
on the target provider). After the apply completes, the
resource is in the state and exists in the cloud. A +
line for a resource that already exists in the cloud is a
drift signal: the state is out of sync with reality; the
configuration will re-create the resource as the
configuration describes it.
- destroy
- means Terraform will destroy the resource. The resource
exists in the state and will be removed. The provider’s
delete call is issued; the state is updated to remove the
record.
- resource "aws_security_group" "legacy" {
- name = "legacy-tier" -> null
}
A - line is the cleanest kind of change provided the
resource is genuinely no longer needed. It becomes dangerous
when the resource owns data (a database, a stateful disk,
an instance with user data on it) and the plan did not
provide an alternative. The plan does not warn you about
owned data; only a review does.
~ update in place
~ is the ambiguous one. - and + semantics are clear;
~ semantics are provider-defined. Most non-forced
attribute changes can be updated in place, but the
provider’s Update API may reject the change, or the
resource schema may declare the attribute as
ForceNew: true, in which case the provider converts the
~ into a -/+ for you. The plan output you see is the
final answer. Trust it.
~ resource "aws_instance" "web" {
~ instance_type = "t3.small" -> "t3.medium"
}
The arrow separates the prior value (from the state) from
the configured value. The apply issues a single
ModifyInstanceAttribute call (or the equivalent). If the
provider rejects the call, the apply stops with an error
and the state is unchanged.
-/+ destroy and create
This is the prefix most engineers should fear and most teams under-review. The attribute cannot be updated in place; the resource must be destroyed and a new one created. The original resource is gone after the destroy phase. Whatever data it owned is gone unless the provider preserves it.
-/+ resource "aws_instance" "web" {
~ ami = "ami-0c7217cdfe345" -> "ami-0c7217cdde317cfec" # forces replacement
~ id = "i-0abc123def456789" -> (known after apply)
instance_type = "t3.small"
}
Every changed attribute is written as old -> new. The
attribute carrying the # forces replacement comment is the
one that made the change a replacement rather than an
in-place update; that comment is the single most useful
thing on the line. The provider will delete the old resource,
create the new one, and update the state in a single apply
phase.
+/- create replacement and then destroy
+/- is the same replacement as -/+ with the order
reversed: the new resource is created first, and the old one
is destroyed once the new one exists. Terraform chooses this
order when the resource block sets
lifecycle { create_before_destroy = true }.
+/- resource "aws_instance" "web" {
~ ami = "ami-0c7217cdfe345" -> "ami-0c7217cdde317cfec" # forces replacement
}
The two objects coexist for the duration of the apply. That
removes the outage window a -/+ opens, and it introduces its
own constraints: unique names, fixed IP addresses, and quota
headroom all have to accommodate two objects at once. A
+/- is still a replacement, so the data-loss rule above
applies unchanged.
<= read
<= means the resource is a data source that Terraform will
read during the apply. It is the read action, not a change
action, and it is not a drift signal. Terraform reads most
data sources at plan time and prints nothing; a <= line
appears when the read has to be deferred because the data
source’s arguments depend on a value that will not be known
until the apply runs.
<= data "aws_ami" "app" {
+ id = (known after apply)
}
The apply issues the read, resolves the value, and feeds it
to whatever resource referenced it. Nothing in the cloud
changes because of a <= line.
Where drift actually appears
Drift found during the refresh is not reported with a plan prefix. Terraform prints it in a separate block above the proposed changes:
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_instance.web has been changed
~ resource "aws_instance" "web" {
id = "i-0abc123def456789"
~ tags = {
+ "Environment" = "production"
}
}
That block is the drift signal to review. Read it before you
read the proposed changes: it tells you what someone else did
to the estate, and the proposed changes below it are
Terraform’s answer to that. terraform plan -refresh-only
shows the same block with no proposed changes attached.
Deferred values: (known after apply)
Some attributes cannot be computed at plan time. The
provider returns the value only when the resource is
created. Terraform marks these values as (known after apply) so the configuration does not rely on a literal
value that does not yet exist.
+ resource "aws_eip" "web" {
+ id = (known after apply)
+ instance = (known after apply)
}
Any attribute can be deferred. A common surprise is the
arn field on a newly created AWS resource: until the
resource is created, the ARN does not exist. Downstream
resources that depend on it via depends_on resolve the
ID at apply time, not plan time. The plan cannot tell you
what the value will be; the apply will tell you, and only
after it has run.
Reading the resource address
The address to the left of the prefix is the canonical location of the resource within the configuration. A long address tells you which module, which instance key, and which resource type and name the change targets.
module.network[0].aws_route_table.public[2]
| | | |
| | | resource name
| | resource type
| module instance key
module call site
Reading the address correctly is half the review. A change
to module.network[0] is a VPC-level change; a change to
module.network["prod"] (a string key) is keyed by
workspace; a change to aws_route_table.public[2] is the
third instance of the public route table in the
configuration.
Annotated plan output
A representative plan with every prefix type annotated:
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
~ update in-place
- destroy
-/+ destroy and then create replacement
+/- create replacement and then destroy
<= read (data resources)
Terraform will perform the following actions:
# aws_security_group.web will be created
+ resource "aws_security_group" "web" {
+ description = "web tier ingress"
+ name = "web-prod"
+ id = (known after apply)
}
# aws_instance.web will be updated in-place
~ resource "aws_instance" "web" {
id = "i-0abc123def456789"
~ instance_type = "t3.small" -> "t3.medium"
}
# aws_instance.legacy must be replaced
-/+ resource "aws_instance" "legacy" {
~ ami = "ami-0old" -> "ami-0new" # forces replacement
~ id = "i-0def456abc789012" -> (known after apply)
}
# aws_instance.blue must be replaced
+/- resource "aws_instance" "blue" {
~ ami = "ami-0old" -> "ami-0new" # forces replacement
~ id = "i-0aaa111bbb222ccc" -> (known after apply)
}
# aws_security_group.legacy will be destroyed
- resource "aws_security_group" "legacy" {
- description = "legacy tier" -> null
- name = "legacy" -> null
}
# data.aws_ami.app will be read during apply
<= data "aws_ami" "app" {
+ id = (known after apply)
}
Plan: 3 to add, 1 to change, 3 to destroy.
The summary line counts three actions only: add, change, and
destroy. There is no replace count. A replacement is counted
once as an add and once as a destroy, which is why the plan
above reports three adds and three destroys when only one
resource is created outright and only one is removed
outright. The only place a replacement is visible is the
-/+ or +/- prefix on the resource itself, so the summary
line cannot tell you whether a plan replaces anything.
Read the resource addresses and the prefixes; use the summary
line as an arithmetic check, not as the review. Every ~ on a
high-blast-radius resource (security groups, route tables, IAM
policies, S3 bucket policies, network ACLs) deserves a closer
read than ~ on a tag.
Cost of misreading -/+
A replace destroys and creates. The new resource has no history. Three production examples:
- Instance replaced because
amiis forced-new. The instance’s ephemeral instance-store volumes disappear. Data on those volumes is lost. EBS volumes declared in the configuration (root_block_deviceor a separateaws_ebs_volume) are detached, then re-attached to the new instance, and the data on them is preserved. - Target group replaced because
health_check.pathis forced-new. The target group is deregistered; in-flight requests are reset; health checks re-run for every target from scratch. A short outage is typical. - DB instance replaced because
engine_versionjumped a major. The DB instance is torn down. Data is destroyed unlessskip_final_snapshot = falseand a snapshot is taken, in which case data is preserved as a snapshot but the live instance is gone and the new instance has a new endpoint.
In each case, the plan told the engineer the replace would
happen. The cost depended on whether the engineer read the
-/+ line.
Validation commands
terraform plan -out=tfplan
terraform show -json tfplan | jq '.resource_changes[] | {address: .address, actions: .change.actions}'
{
"address": "aws_security_group.web",
"actions": [ "create" ]
}
{
"address": "aws_instance.web",
"actions": [ "update" ]
}
{
"address": "aws_instance.legacy",
"actions": [ "delete", "create" ]
}
{
"address": "aws_instance.blue",
"actions": [ "create", "delete" ]
}
{
"address": "aws_security_group.legacy",
"actions": [ "delete" ]
}
{
"address": "data.aws_ami.app",
"actions": [ "read" ]
}
The JSON output’s change.actions array is the authoritative
source of the action tuple, and it takes one of seven values:
["no-op"], ["create"], ["read"], ["update"],
["delete"], ["delete", "create"], and
["create", "delete"]. They map to the prefixes as
["create"] to +; ["update"] to ~; ["delete"] to -;
["delete", "create"] to -/+; ["create", "delete"] to
+/-; and ["read"] to <=. ["no-op"] prints nothing.
Scanning the tuple for delete catches all three cases where
an object is destroyed. Do not parse the coloured terminal
output in CI; parse the JSON.
Production failure modes
- The reviewer noticed only
+. A 200-line plan shows 12+lines and 1~line on a security group’scidr_blocks. The reviewer approves. The~opened port 22 to a wider CIDR. The fix is to read every~line, not just+and-. - The reviewer mistook
-/+for~. The summary line read1 to add, 0 to change, 1 to destroyand the reviewer read it as two unrelated resources rather than one replacement. The instance was destroyed. The fix is to grep the plan body for-/+and+/-and flag those rows separately in the PR, because the summary line does not distinguish a replacement from an unrelated add and destroy. (known after apply)was treated as a literal value. A downstream module passedaws_instance.web.idinto auser_datatemplate as if it were known. The apply failed because the value was not yet available. The fix is to let the reference itself carry the ordering: interpolate the attribute rather than a hard-coded string, and Terraform defers the read until the value exists.- The drift block was scrolled past. The plan opened with
Note: Objects have changed outside of Terraformand the reviewer skipped to the proposed changes. The proposed changes looked ordinary, so the PR was approved without anyone asking who had edited the estate by hand. The fix is to read the drift block first and record the cause of every entry in it in the PR. - A
~on a tag was rolled into a bulk approval. Tag changes are~; the reviewer is not in the habit of reading them. A~on a tag that controlled autoscaler behaviour (tags["k8s.io/cluster-autoscaler/enabled"]) was missed. The fix is a tag policy in code review that requires an additional approver for tag-only diffs on sensitive keys. - A
-/+on an Elastic IP was approved without checking the allocation. The new instance received a new EIP. The DNS records that pointed to the old EIP kept pointing at the old address. The fix is to import the old EIP into the new instance in the same apply, or pre-update the DNS records in a coordinated change.
Security and performance
The prefixes themselves carry no security risk. The risk is
in misreading them. The -/+ symbol on a resource that owns
secrets (an aws_secretsmanager_secret referenced by ID
elsewhere) is a data-loss event; the secret itself is gone
unless a snapshot was taken, and the ID of the new secret
differs from the old.
A plan that touches many resources scales with the number
of attribute lookups the provider performs. AWS plans can
take 30 seconds to several minutes at the upper end. Use
-refresh=false only when you have a reason; the refresh is
what populates the Objects have changed outside of Terraform
block, so a plan that skips it cannot report drift.
Production guidance
- Count the replacements yourself. The summary line reports
only
N to add, N to change, N to destroy; a replacement is folded into the add and destroy counts. Grep the plan for-/+and+/-to get the high-blast-radius count. - Treat both replacement prefixes as data-loss until proven otherwise. Confirm the resource has no owned data before approving.
- Use
-no-colorin CI. ANSI colour codes make the diff unreadable in stored artifacts and in PR comments scraped for audit. - Use
-jsonfor any machine-readable extraction of the plan. The action tuple in the JSON is the authoritative source. - Decide where the plan artefact is allowed to go before you
produce one. Terraform redacts sensitive values from the
terminal output, but a plan saved with
-outstores them in cleartext andterraform show -jsonrenders them in full. There is no flag that changes this, so the control is the storage and the review channel, not the command line.
What comes next
The next lesson is the saved plan artifact: how to serialise a plan to a file, how to ship the file from the plan stage to the apply stage, what the file contains, and why a plan file is itself a sensitive artefact.
Verification
terraform plan -out=tfplan
terraform show -json tfplan | jq '.resource_changes[] | {address: .address, actions: .change.actions}'
{
"address": "aws_security_group.web",
"actions": [ "create" ]
}
{
"address": "aws_instance.web",
"actions": [ "update" ]
}
{
"address": "aws_instance.legacy",
"actions": [ "delete", "create" ]
}
{
"address": "aws_instance.blue",
"actions": [ "create", "delete" ]
}
{
"address": "aws_security_group.legacy",
"actions": [ "delete" ]
}
{
"address": "data.aws_ami.app",
"actions": [ "read" ]
}
Walk each entry. Cross-check the action against what you
expect. Any tuple containing delete needs extra review, and
that is the point of scanning for it rather than for the
prefix: [ "delete" ], [ "delete", "create" ], and
[ "create", "delete" ] all destroy an object. An
[ "no-op" ] entry in a configuration that was supposed to
make a change is a signal that the change missed.
Knowledge check · 7 questions
Q1. What does the -/+ prefix mean in a Terraform plan?
Q2. Which prefix tells the reviewer that a data source will be read during the apply rather than at plan time?
Q3. A ~ (update in place) is always a safe in-place mutation of the cloud resource.
Q4. A plan shows + aws_eip.web with id listed as (known after apply). What does this tell the reviewer?
Q5. Which of the following prefixes indicate that the resource address will not exist in the cloud after the apply completes? (Select all that apply.)
Q6. Why is -/+ the most consequential prefix in a production plan?
Q7. A plan shows ~ aws_instance.app changing only user_data. The reviewer approves. After apply, users report their home directories and ephemeral data are gone. What most likely happened?
Passing score: 75%. Answers are checked in this browser.