Idempotency #
Idempotency is the property where running the same operation multiple times produces the same result as running it once. In Terraform, this means you can run terraform apply repeatedly without worrying about creating duplicate resources, changing something unnecessarily, or breaking a condition that’s already correct.
This isn’t a coincidence — idempotency is a deliberate design feature of Terraform, achieved through three mechanisms working together: state, plan, and refreshing from the actual condition. Understanding it helps you recognize when your configuration or Terraform usage deviates from this principle.
How Idempotency Works #
flowchart TD
A["terraform apply\n(run repeatedly)"] --> B["1. Read state\nWhat already exists?"]
B --> C["2. Read configuration\nWhat should exist?"]
C --> D["3. Refresh from APIs\nWhat's the actual condition?"]
D --> E{"Compare:\nstate vs configuration\nvs actual condition"}
E -->|"All the same\n(state ≈ config ≈ actual)"| F["✅ 'No changes'\nNo operations"]
E -->|"There are differences"| G["🔄 Only the differences\nare executed"]
F --> H["Second, third,\nfourth apply...\nalways the SAME result"]
G --> I["Fix the differences\nState updated"]
style F fill:#e8f5e9,stroke:#2e7d32
style G fill:#e3f2fd,stroke:#1565c0
style H fill:#e8f5e9,stroke:#2e7d32HOW IDEMPOTENCY WORKS:
Terraform apply (first time):
State: empty
Configuration: 3 resources
Plan: +3 create
Result: 3 resources created, state updated
Terraform apply (second time, no changes):
State: 3 resources
Configuration: 3 resources (same)
Refresh: actual condition = state = configuration
Plan: "No changes. Your infrastructure matches the configuration."
Result: nothing changed ✅
Terraform apply (third, fourth, fifth...):
Same as the second time — no changes ✅
| Step | Mechanism | Function |
|---|---|---|
| 1. Read state | State file | Know what already exists in the cloud |
| 2. Read configuration | .tf files | Know what should exist |
| 3. Refresh | Cloud APIs | Verify the actual condition in the cloud |
| 4. Compare | Diff engine | Find differences between the three |
| 5. Execute | Apply engine | Only run the necessary changes |
Comparison with Non-Idempotent Approaches #
flowchart TD
subgraph "Bash Script (Non-Idempotent)"
BA["chmod +x deploy.sh\n& ./deploy.sh"] --> BB["First time:\nsucceeds ✅"]
BA --> BC["Second time:\nERROR or\nduplicates ❌"]
BA --> BD["Needs conditional logic:\nif ! exists, then create"]
end
subgraph "Terraform (Idempotent)"
TA["terraform apply"] --> TB["First time:\nsucceeds ✅"]
TA --> TC["Second time:\nNo changes ✅"]
TA --> TD["Third time:\nNo changes ✅"]
end
style BB fill:#e8f5e9,stroke:#2e7d32
style BC fill:#ffebee,stroke:#c62828
style BD fill:#fff3e0,stroke:#e65100
style TB fill:#e8f5e9,stroke:#2e7d32
style TC fill:#e8f5e9,stroke:#2e7d32
style TD fill:#e8f5e9,stroke:#2e7d32# ❌ ANTI-PATTERN: Bash script — not idempotent
#!/bin/bash
# First run: succeeds
aws ec2 create-security-group \
--group-name "web-sg" \
--description "Web security group"
# Second run: ERROR
# An error occurred (InvalidGroup.Duplicate):
# The security group 'web-sg' already exists
# Needs extra conditional logic to become idempotent:
if ! aws ec2 describe-security-groups --group-names "web-sg" 2>/dev/null; then
aws ec2 create-security-group --group-name "web-sg" ...
fi
# And this still doesn't handle race conditions, updates, etc.
# ✅ CORRECT: Terraform — idempotent by design
resource "aws_security_group" "web" {
name = "web-sg"
description = "Web security group"
}
# Run it 100 times → the result is ALWAYS the same
# Terraform knows whether the security group already exists
# and only acts when there are differences
| Aspect | Bash Script | Terraform |
|---|---|---|
| Run twice | Error or duplicates | “No changes” |
| Detecting existing resources | Manual (if/else) | Automatic (state + refresh) |
| Updating what changed | Manual (grep, diff) | Automatic (plan engine) |
| Handling race conditions | Hard | Built-in (state locking) |
| Extra code | Lots of conditional logic | None needed |
Cases That Can Break Idempotency #
Although Terraform is designed to be idempotent, there are several patterns that can break it. Learn these patterns so your configuration stays idempotent.
flowchart TD
A["Cases that break\nidempotency"] --> B["timestamp() / uuid()\nin the configuration"]
A --> C["Non-idempotent\nprovisioners"]
A --> D["Resources managed\nby two tools"]
A --> E["Values that change\nevery plan/apply"]
B --> B1["Different value every apply\n→ update every time"]
C --> C1["Script re-runs whenever\nthe resource is replaced"]
D --> D1["Tool A changes, Terraform reverts\nTool A changes again, and so on"]
E --> E1["random_id, random_password\nwithout lifecycle ignore"]
B1 --> F["Solution: Use\nstable values or\nlifecycle ignore_changes"]
C1 --> F
D1 --> G["Solution: Let only\none tool manage it"]
E1 --> F
style B fill:#ffebee,stroke:#c62828
style C fill:#ffebee,stroke:#c62828
style D fill:#ffebee,stroke:#c62828
style E fill:#ffebee,stroke:#c62828
style F fill:#e8f5e9,stroke:#2e7d32
style G fill:#e8f5e9,stroke:#2e7d32Case 1: timestamp() or uuid() in the Configuration
#
# ❌ ANTI-PATTERN: Every apply produces a different value
resource "aws_s3_bucket_object" "config" {
bucket = aws_s3_bucket.main.id
key = "config.json"
content = jsonencode({
generated_at = timestamp() # ✗ Different every apply!
# This causes the resource to be updated EVERY time apply runs
})
}
# ✅ CORRECT: Use a stable value
resource "aws_s3_bucket_object" "config" {
bucket = aws_s3_bucket.main.id
key = "config.json"
content = jsonencode({
environment = var.environment # Only changes if the variable changes
version = var.app_version # Only changes if the variable changes
})
}
Case 2: Non-Idempotent Provisioners #
# ❌ ANTI-PATTERN: The provisioner re-runs every time the resource is "new"
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = "t3.micro"
provisioner "remote-exec" {
inline = [
"echo 'Server $(hostname) started at $(date)' >> /var/log/deploy.log"
# the timestamp here will differ on every apply
]
}
}
# ✅ CORRECT: Use user_data for initial configuration
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = "t3.micro"
user_data = file("scripts/setup.sh") # Runs only once at launch
}
Case 3: Resources Managed by Two Tools #
flowchart LR
A["Terraform\nmanages the resource"] -->|"Updates to\nthe desired state"| B["Cloud Resource"]
C["Another tool\n(kubectl, console)"] -->|"Updates to\na different state"| B
B -->|"Terraform detects\ndrift"| A
B -->|"The other tool detects\ndrift"| C
A --> D["Infinite loop:\nTerraform ↔ other tool\noverwriting each other"]
style D fill:#ffebee,stroke:#c62828| Case | Cause | Solution |
|---|---|---|
timestamp() / uuid() | Different value every apply | Use stable values or ignore_changes |
| Non-idempotent provisioners | Script re-runs on every replace | Use user_data or Ansible |
| Dual management | Two tools managing the same resource | Let only one tool manage it |
random_id / random_password | Values regenerate | lifecycle { ignore_changes = [...] } |
file() on a changing file | File content changes | Make sure the file is stable or use filemd5 |
Verifying Your Configuration’s Idempotency #
There’s a simple way to verify that your configuration is truly idempotent.
flowchart TD
A["Idempotency Test"] --> B["1. terraform apply\n(first time)"]
B --> C["2. terraform plan\n(without changing config)"]
C --> D{"Does the plan show\nchanges?"}
D -->|"No: No changes"| E["✅ Idempotent!\nConfiguration is safe"]
D -->|"Yes: there are changes"| F["❌ Not idempotent!\nFind the cause"]
F --> G["Check timestamp(),\nuuid(), random values,\nor external changes"]
style E fill:#e8f5e9,stroke:#2e7d32
style F fill:#ffebee,stroke:#c62828# Manual idempotency test:
# 1. Apply the first time
terraform apply -auto-approve
# 2. Check without changing anything (faster than apply)
terraform plan
# 3. Check the output
# If idempotent:
# "No changes. Your infrastructure matches the configuration."
#
# If NOT idempotent:
# Plan: 0 to add, 1 to change, 0 to destroy.
# → There are changes appearing without you changing the config
# → Find the cause: timestamp, random values, or external changes
# Alternative: Apply a second time to verify
terraform apply -auto-approve
# Desired output:
# Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
| Step | Command | Idempotent Result | Non-Idempotent Result |
|---|---|---|---|
| 1. First apply | terraform apply | Resources created | Resources created |
| 2. Check | terraform plan | “No changes” | There are changes |
| 3. Second apply | terraform apply | 0 added, 0 changed | Unexpected changes |
Idempotency and Immutable Infrastructure #
Idempotency doesn’t mean resources never change — it means running the same configuration always produces a consistent result. This aligns with the principles of immutable infrastructure.
flowchart TD
subgraph "Mutable (Traditional)"
M1["Server created"] --> M2["Reconfigured\nrepeatedly"]
M2 --> M3["Condition changes\nwithout a trace"]
M3 --> M4["'Configuration drift'\nHard to predict"]
end
subgraph "Immutable (Modern)"
I1["Server created\nwith config A"] --> I2["Need config B?"]
I2 --> I3["Old server deleted\nNew server created\nwith config B"]
I3 --> I4["Condition is ALWAYS\npredictable"]
end
style M4 fill:#ffebee,stroke:#c62828
style I4 fill:#e8f5e9,stroke:#2e7d32| Aspect | Mutable | Immutable |
|---|---|---|
| Update | Change in place | Delete + create new |
| State tracking | Hard | Easy (always fresh) |
| Rollout risk | Hidden changes | Clearly visible |
| Terraform action | update in-place (~) | destroy and recreate (-/+) |
| Idempotency | Requires extra effort | Natural |
TERRAFORM SUPPORTS BOTH:
- Update in-place (~): mutable, but controlled
- Destroy and recreate (-/+): immutable approach
- The choice depends on the resource type and needs
- Lifecycle create_before_destroy: a safer transition
Summary #
- Terraform’s idempotency is built-in through three mechanisms: state, plan, and refreshing from the actual condition.
- “No changes” on the second plan/apply is a sign that your configuration is idempotent — that’s the desired condition.
- Avoid
timestamp(),uuid(), or random values in resource configurations — they break idempotency because their values change on every apply.- Provisioners aren’t idempotent by nature — consider
user_dataor Ansible as alternatives.- One tool per resource — don’t manage the same resource with Terraform and another tool simultaneously.
- Test idempotency by running
terraform planafter the first apply — if changes still appear, something needs fixing.- Idempotency is the foundation of trust in Terraform configurations — it enables safe re-runs in CI/CD, safe retries after failures, and safe collaboration between teams.