Plan #

terraform plan is Terraform’s main safety net. Before a single resource changes in the cloud, you get the chance to see exactly what’s about to happen. But the plan output isn’t just a list to scroll through — there are important details you need to understand to read a plan critically, not just accept it at face value.

How Plan Works #

Before producing output, terraform plan performs a series of internal operations. Understanding this sequence helps you know why plans sometimes show changes you didn’t expect.

flowchart TD
    A["terraform plan"] --> B["1. Read all .tf files\nParse HCL configuration"]
    B --> C["2. Refresh state\nQuery actual state from provider APIs"]
    C --> D["3. Compare configuration vs state\nIdentify differences"]
    D --> E["4. Calculate the dependency graph\nDetermine the operation order"]
    E --> F["5. Show the execution plan\nWhat changes will be made"]

    C -.->|"Manual changes\nin the cloud"| G["Recorded as\nchanges in the plan"]
    D -.->|"Any changes?"| H["Yes → show them\nNo → no changes"]

    style A fill:#e3f2fd,stroke:#1565c0
    style F fill:#e8f5e9,stroke:#2e7d32
    style G fill:#fff3e0,stroke:#e65100
StepWhat It DoesOutputWhy It Matters
1. Read configParse all .tf filesInternal configuration representationSyntax errors are caught here
2. Refresh stateQuery the provider APIsActual resource data in the cloudReflects reality, not just the old state file
3. CompareConfig vs actual stateList of differencesCatches manual changes in the cloud
4. Dependency graphCalculate the dependency orderOperation orderDependent resources are handled in the right order
5. Show the planFormat output for the userList of changesFinal decision: apply or not

The refresh step at number 2 means the plan always reflects the actual cloud condition — not just what’s stored in the local state file. If someone changed a resource directly in the cloud console (for example, changing an instance type in the AWS Console), plan will detect it.

Reading Plan Output #

The plan output uses symbols to indicate the operation type on each resource. Reading this output quickly is a crucial skill.

flowchart TD
    A["Symbols in Plan Output"] --> B["+ CREATE\nA new resource will be created"]
    A --> C["~ UPDATE IN-PLACE\nThe resource is modified without replacement"]
    A --> D["- DESTROY\nThe resource will be deleted"]
    A --> E["-/+ REPLACE\nDeleted then recreated"]
    A --> F["<= READ\nThe data source will be read"]

    B --> B1["✅ Relatively safe\nNo impact on existing resources"]
    C --> C1["✅ Usually safe\nSpecific attributes change"]
    D --> D1["⚠️ Be careful\nPermanent data loss"]
    E --> E1["🔴 Most dangerous\nDowntime + data loss"]

    style B fill:#e8f5e9,stroke:#2e7d32
    style C fill:#e3f2fd,stroke:#1565c0
    style D fill:#ffebee,stroke:#c62828
    style E fill:#ffebee,stroke:#c62828
    style F fill:#f3e5f5,stroke:#7b1fa2
    style B1 fill:#e8f5e9,stroke:#2e7d32
    style C1 fill:#e3f2fd,stroke:#1565c0
    style D1 fill:#fff3e0,stroke:#e65100
    style E1 fill:#ffebee,stroke:#c62828
SymbolMeaningImpactRisk
+CreateA new resource is createdLow — no impact on existing resources
~Update in-placeAttributes change without replacementLow-Medium — depends on the attribute
-DestroyThe resource is permanently deletedHigh — data loss
-/+Replace (destroy + create)Deleted then recreatedVery high — downtime + data loss
<=ReadThe data source is readNone — read-only
$ terraform plan

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

Terraform will perform the following actions:

  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                    = "ami-0abcdef1234567890"
      + id                     = (known after apply)
      + instance_type          = "t3.micro"
      + public_ip              = (known after apply)
      + tags                   = {
          + "Name" = "web-server"
        }
    }

  # aws_security_group.web will be updated in-place
  ~ resource "aws_security_group" "web" {
        id   = "sg-0abcdef1234567890"
        name = "web-sg"
      ~ ingress = [
          - {
              - from_port   = 22
              - protocol    = "tcp"
              - to_port     = 22
            },
          + {
              + from_port   = 443
              - protocol    = "tcp"
              + to_port     = 443
            },
        ]
    }

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

The Summary Line at the End of the Plan #

At the end of the plan output, Terraform shows a summary giving you a quick overview:

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

# Another example:
# Plan: 5 to add, 0 to change, 2 to destroy.
# → 5 new resources, no changes, 2 will be deleted

# No changes. Your infrastructure matches the configuration.
# → No changes — the configuration already matches the actual condition

Beware of Replace (-/+) #

Replace is the operation that most often trips up Terraform developers. Some attribute changes trigger a replace, not an update in-place. You can detect it from the # forces replacement comment in the plan output.

flowchart TD
    A["Resource attribute\nchange"] --> B{"Type of attribute\nchanging?"}

    B -->|"Attribute that can\nbe updated"| C["Update in-place (~)\nResource keeps running"]
    B -->|"Attribute that forces\nreplacement"| D["Replace (-/+)\nResource deleted + recreated"]

    D --> E["What happens?"]
    E --> F["1. Old instance deleted"]
    F --> G["2. New instance created"]
    G --> H["⚠️ All data on the\nold instance is lost!"]

    D --> I["Prevent with a\nlifecycle block"]

    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#ffebee,stroke:#c62828
    style H fill:#ffebee,stroke:#c62828
    style I fill:#e3f2fd,stroke:#1565c0

Examples of Changes That Trigger a Replace #

# Examples of changes that trigger a REPLACE (not an update):

resource "aws_instance" "web" {
  # ANTI-PATTERN: Changing the AMI on a running instance
  ami = "ami-NEW"  # Changing the AMI → forces replacement
                   # The old instance is deleted, a new one is created
                   # All data on the old instance's disk is lost
}

# Changes that trigger a replace are marked with "# forces replacement"
# in the plan output:
#
# -/+ resource "aws_instance" "web" {
#     ~ ami = "ami-OLD" -> "ami-NEW" # forces replacement
#     - id  = "i-0abcdef1234567890"
#     + id  = (known after apply)
#   }
ResourceAttributes That Trigger a ReplaceImpact
aws_instanceami, instance_type (some cases)New instance, IP changes
aws_db_instanceengine, instance_class (some cases)New database, data needs migration
aws_s3_bucketbucket (bucket name)New bucket, old data orphaned
aws_security_groupChanges to nameNew SG, needs re-attaching
aws_vpccidr_blockNew VPC, all subnets need recreating

Solution: create_before_destroy #

# CORRECT: Use the create_before_destroy lifecycle to minimize downtime
resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = "t3.micro"

  lifecycle {
    create_before_destroy = true
    # Create the new instance first before the old one is deleted
    # Minimizes downtime, but requires unique resource naming
  }
}
flowchart LR
    subgraph "Without create_before_destroy"
        OLD1["Old instance\ni-0abc123"] -->|"destroy"| NOTHING["❌ Downtime\nuntil the new\ninstance is ready"]
        NOTHING -->|"create"| NEW1["New instance\ni-0def456"]
    end

    subgraph "With create_before_destroy"
        OLD2["Old instance\ni-0abc123"] -.->|"still running"| BOTH["✅ New instance\nready first"]
        NEW2["New instance\ni-0def456"] -->|"create"| BOTH
        BOTH -->|"destroy"| DONE["Old instance\ndeleted"]
    end

    style NOTHING fill:#ffebee,stroke:#c62828
    style BOTH fill:#e8f5e9,stroke:#2e7d32
    style DONE fill:#e8f5e9,stroke:#2e7d32

Saved Plans for Safe Deployment #

There’s a gap between when you run plan and when you run apply. In that window, the cloud condition can change. For production deployments, use a saved plan.

flowchart LR
    subgraph "Without Saved Plan ⚠️"
        P1["terraform plan\nat 10:00"] -->|"Time passes...\nconditions change"| A1["terraform apply\nat 10:30"]
        A1 --> R1["⚠️ The apply differs\nfrom what was reviewed!"]
    end

    subgraph "With Saved Plan ✅"
        P2["terraform plan\n-out=tfplan\nat 10:00"] -->|"tfplan saved\nas a binary"| A2["terraform apply\ntfplan\nat 10:30"]
        A2 --> R2["✅ Applies exactly\nwhat was reviewed"]
    end

    style R1 fill:#fff3e0,stroke:#e65100
    style R2 fill:#e8f5e9,stroke:#2e7d32
# Save the plan to a file
terraform plan -out=tfplan

# Apply exactly from the saved plan
# Terraform won't generate a new plan — it executes the saved one directly
terraform apply tfplan

# Benefits:
# - What's reviewed is exactly what's applied
# - No surprises from condition changes between plan and apply
# - Great for GitOps workflows: plan in the PR, apply after merge

# To view the contents of a saved plan in a readable format:
terraform show tfplan
WorkflowPlanApplyRisk
Without saved planterraform planterraform applyConditions can change between plan and apply
With saved planterraform plan -out=tfplanterraform apply tfplanNone — applies exactly like the plan
CI/CD pipelineterraform plan -out=tfplan (in the PR)terraform apply tfplan (after merge)Minimal — already reviewed
The tfplan file contains the entire configuration and state in binary format. It can contain sensitive values. Don’t commit this file to Git, and delete it after use.

Useful Plan Flags #

FlagFunctionWhen to Use
-var="key=value"Override a variableTesting with different values
-var-file="file.tfvars"Variable fileEnvironment-specific config
-target=resourcePlan only specific resourcesDebugging specific resources
-refresh=falseSkip state refreshFaster plans (less accurate)
-out=tfplanSave the plan to a fileProduction deployments
-parallelism=NLimit concurrent opsAPI rate limits
# Plan with variable overrides
terraform plan -var="environment=production"
terraform plan -var-file="production.tfvars"

# Plan only for specific resources (target planning)
terraform plan -target=aws_instance.web
terraform plan -target=module.vpc

# Skip state refresh (faster, but may not reflect the actual condition)
terraform plan -refresh=false

# Show the plan in JSON format (for programmatic parsing)
terraform plan -out=tfplan && terraform show -json tfplan | jq .

# Plan with a limited number of concurrent operations
terraform plan -parallelism=5  # default: 10
-target is useful for debugging, but don’t use it routinely. A plan with -target only sees part of the dependency graph — untargeted resources may have invisible dependencies, causing inconsistent state after apply.

When a Plan Can Mislead #

Plans aren’t always 100% accurate. There are conditions where what the plan shows doesn’t fully reflect what will happen.

flowchart TD
    A["A plan can mislead when..."] --> B["1. Computed values\naren't known yet"]
    A --> C["2. Changes can't\nbe predicted"]
    A --> D["3. External changes\nnot yet synced"]
    A --> E["4. Order of operations\ncan change"]

    B --> B1["(known after apply)\nIP addresses, IDs, etc.\nonly known after apply"]
    C --> C1["Examples: random_password,\ntimestamp, UUID\nAlways change on every plan"]
    D --> D1["If someone changed\na resource in the cloud\nwithout going through Terraform"]
    E --> E1["In rare cases, the order\ncan differ from what's expected"]

    style B1 fill:#fff3e0,stroke:#e65100
    style C1 fill:#fff3e0,stroke:#e65100
    style D1 fill:#ffebee,stroke:#c62828
    style E1 fill:#fff3e0,stroke:#e65100
SituationWhy It MisleadsSolution
(known after apply) valuesValues aren’t known until the resource is createdNormal — unavoidable
random_* or time_* resourcesAlways show changesUse lifecycle { ignore_changes }
Manual changes in the cloudState hasn’t been refreshedRun terraform refresh before planning
Data sources changingData sources refresh on every planUse -refresh=false to speed up
# Example: A random resource that always changes in plans
resource "random_password" "db" {
  length = 16
  # Every time a plan runs, it shows "1 to change"
  # because random_password generates a new value each time

  # SOLUTION: Ignore changes after the first creation
  lifecycle {
    ignore_changes = [result]
  }
}

Summary #

  • Plan always refreshes state before calculating changes — it reflects the actual cloud condition, not just the local state file.
  • Five symbols: + create (safe), ~ update in-place (usually safe), - destroy (be careful), -/+ replace (most dangerous), <= read (safe).
  • -/+ is the most dangerous — the resource is deleted and recreated, meaning potential downtime and data loss. Look for the # forces replacement marker in the plan output.
  • Use create_before_destroy in the lifecycle block to minimize downtime when a replace is unavoidable.
  • Use saved plans (-out=tfplan) for production — make sure what’s reviewed is exactly what’s applied.
  • -target is useful for debugging, but avoid routine use because it can hide dependencies.
  • Random/time resources always show changes — use lifecycle { ignore_changes } to avoid plan noise.

← Previous: Init   Next: Apply →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact