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:#2e7d32
HOW 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 ✅
StepMechanismFunction
1. Read stateState fileKnow what already exists in the cloud
2. Read configuration.tf filesKnow what should exist
3. RefreshCloud APIsVerify the actual condition in the cloud
4. CompareDiff engineFind differences between the three
5. ExecuteApply engineOnly 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
AspectBash ScriptTerraform
Run twiceError or duplicates“No changes”
Detecting existing resourcesManual (if/else)Automatic (state + refresh)
Updating what changedManual (grep, diff)Automatic (plan engine)
Handling race conditionsHardBuilt-in (state locking)
Extra codeLots of conditional logicNone 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:#2e7d32

Case 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
CaseCauseSolution
timestamp() / uuid()Different value every applyUse stable values or ignore_changes
Non-idempotent provisionersScript re-runs on every replaceUse user_data or Ansible
Dual managementTwo tools managing the same resourceLet only one tool manage it
random_id / random_passwordValues regeneratelifecycle { ignore_changes = [...] }
file() on a changing fileFile content changesMake 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.
StepCommandIdempotent ResultNon-Idempotent Result
1. First applyterraform applyResources createdResources created
2. Checkterraform plan“No changes”There are changes
3. Second applyterraform apply0 added, 0 changedUnexpected 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
AspectMutableImmutable
UpdateChange in placeDelete + create new
State trackingHardEasy (always fresh)
Rollout riskHidden changesClearly visible
Terraform actionupdate in-place (~)destroy and recreate (-/+)
IdempotencyRequires extra effortNatural
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_data or Ansible as alternatives.
  • One tool per resource — don’t manage the same resource with Terraform and another tool simultaneously.
  • Test idempotency by running terraform plan after 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.

← Previous: Execution Plan   Next: Drift Detection →

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