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| Step | What It Does | Output | Why It Matters |
|---|---|---|---|
| 1. Read config | Parse all .tf files | Internal configuration representation | Syntax errors are caught here |
| 2. Refresh state | Query the provider APIs | Actual resource data in the cloud | Reflects reality, not just the old state file |
| 3. Compare | Config vs actual state | List of differences | Catches manual changes in the cloud |
| 4. Dependency graph | Calculate the dependency order | Operation order | Dependent resources are handled in the right order |
| 5. Show the plan | Format output for the user | List of changes | Final 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| Symbol | Meaning | Impact | Risk |
|---|---|---|---|
+ | Create | A new resource is created | Low — no impact on existing resources |
~ | Update in-place | Attributes change without replacement | Low-Medium — depends on the attribute |
- | Destroy | The resource is permanently deleted | High — data loss |
-/+ | Replace (destroy + create) | Deleted then recreated | Very high — downtime + data loss |
<= | Read | The data source is read | None — 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:#1565c0Examples 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)
# }
| Resource | Attributes That Trigger a Replace | Impact |
|---|---|---|
aws_instance | ami, instance_type (some cases) | New instance, IP changes |
aws_db_instance | engine, instance_class (some cases) | New database, data needs migration |
aws_s3_bucket | bucket (bucket name) | New bucket, old data orphaned |
aws_security_group | Changes to name | New SG, needs re-attaching |
aws_vpc | cidr_block | New 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:#2e7d32Saved 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
| Workflow | Plan | Apply | Risk |
|---|---|---|---|
| Without saved plan | terraform plan | terraform apply | Conditions can change between plan and apply |
| With saved plan | terraform plan -out=tfplan | terraform apply tfplan | None — applies exactly like the plan |
| CI/CD pipeline | terraform 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 #
| Flag | Function | When to Use |
|---|---|---|
-var="key=value" | Override a variable | Testing with different values |
-var-file="file.tfvars" | Variable file | Environment-specific config |
-target=resource | Plan only specific resources | Debugging specific resources |
-refresh=false | Skip state refresh | Faster plans (less accurate) |
-out=tfplan | Save the plan to a file | Production deployments |
-parallelism=N | Limit concurrent ops | API 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
-targetis useful for debugging, but don’t use it routinely. A plan with-targetonly 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| Situation | Why It Misleads | Solution |
|---|---|---|
(known after apply) values | Values aren’t known until the resource is created | Normal — unavoidable |
random_* or time_* resources | Always show changes | Use lifecycle { ignore_changes } |
| Manual changes in the cloud | State hasn’t been refreshed | Run terraform refresh before planning |
| Data sources changing | Data sources refresh on every plan | Use -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 replacementmarker in the plan output.- Use
create_before_destroyin 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.-targetis 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.