Skip to main content
RunBook Academy

TerraformXVI · Plan Review and Saved PlansProduction Terraform

Plan Symbols: Create, Update, Destroy, Replace

Foundation⏱ ~14 minbash

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

Not yet marked complete on this device.

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:

  1. Instance replaced because ami is forced-new. The instance’s ephemeral instance-store volumes disappear. Data on those volumes is lost. EBS volumes declared in the configuration (root_block_device or a separate aws_ebs_volume) are detached, then re-attached to the new instance, and the data on them is preserved.
  2. Target group replaced because health_check.path is 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.
  3. DB instance replaced because engine_version jumped a major. The DB instance is torn down. Data is destroyed unless skip_final_snapshot = false and 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

  1. The reviewer noticed only +. A 200-line plan shows 12 + lines and 1 ~ line on a security group’s cidr_blocks. The reviewer approves. The ~ opened port 22 to a wider CIDR. The fix is to read every ~ line, not just + and -.
  2. The reviewer mistook -/+ for ~. The summary line read 1 to add, 0 to change, 1 to destroy and 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.
  3. (known after apply) was treated as a literal value. A downstream module passed aws_instance.web.id into a user_data template 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.
  4. The drift block was scrolled past. The plan opened with Note: Objects have changed outside of Terraform and 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.
  5. 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.
  6. 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-color in CI. ANSI colour codes make the diff unreadable in stored artifacts and in PR comments scraped for audit.
  • Use -json for 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 -out stores them in cleartext and terraform show -json renders 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

  1. Q1. What does the -/+ prefix mean in a Terraform plan?

  2. Q2. Which prefix tells the reviewer that a data source will be read during the apply rather than at plan time?

  3. Q3. A ~ (update in place) is always a safe in-place mutation of the cloud resource.

  4. Q4. A plan shows + aws_eip.web with id listed as (known after apply). What does this tell the reviewer?

  5. 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.)

  6. Q6. Why is -/+ the most consequential prefix in a production plan?

  7. 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.