Skip to content
Infrastructure as Code iac concepts 4 min read

Idempotency

Idempotency is the property that applying the same configuration any number of times produces the same result as applying it once. With Terraform, if your infrastructure already matches the config you wrote, a second apply does nothing — it reports “No changes.” This is what makes Terraform safe to run on every commit, in CI, or on a cron, without fear of duplicating resources or accumulating side effects.

What idempotency means in practice

A Terraform run is idempotent when re-running it against already-converged infrastructure yields a no-op plan: zero resources to add, change, or destroy. The desired state described in your .tf files matches the real-world state, so there is nothing for Terraform to do.

Consider a simple bucket:

resource "aws_s3_bucket" "logs" {
  bucket = "devcraftly-app-logs-2026"

  tags = {
    Environment = "production"
    ManagedBy   = "terraform"
  }
}

The first terraform apply creates the bucket. The second apply, with no changes to the file, does nothing:

Output:

aws_s3_bucket.logs: Refreshing state... [id=devcraftly-app-logs-2026]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

You could run this a hundred times and the result would never differ. That repeatability is the whole point.

Why declarative tools are idempotent

Terraform is declarative: you describe the end state you want, not the steps to reach it. On every run Terraform performs the same three-stage cycle — refresh the real state, diff it against your declared config, then reconcile the gap. When there is no gap, there is no work.

This differs fundamentally from imperative tooling, where you write the steps yourself. A shell script that runs aws s3api create-bucket will succeed the first time and fail or duplicate on the second, because “create a bucket” is an action, not a goal. The script does not know the bucket already exists unless you write that check by hand.

AspectDeclarative (Terraform)Imperative (shell / CLI)
You specifyThe desired end stateThe exact steps to run
Re-runningNo-op when already convergedRepeats actions; may fail or duplicate
Existing-resource handlingBuilt in via state + refreshYou must code every guard yourself
Drift detectionSurfaced automatically in the planNot detected unless you add checks
Reasoning model”Make reality look like this""Do these commands in order”

Because Terraform derives the action set from a diff rather than from a fixed list of commands, idempotency is an emergent property of the engine — not something you have to engineer per resource.

Idempotency depends on resource providers implementing read/refresh correctly. A well-behaved provider can always look up a resource by its ID and report its current attributes, which is how Terraform knows whether anything changed.

Contrast with an imperative script

Here is the imperative equivalent of the bucket above, and why it is not idempotent:

# Not idempotent: second run fails because the bucket already exists
aws s3api create-bucket \
  --bucket devcraftly-app-logs-2026 \
  --region us-east-1

Output:

An error occurred (BucketAlreadyOwnedByYou) when calling the
CreateBucket operation: Your previous request to create the named
bucket succeeded and you already own it.

To make the script idempotent you would have to add an existence check, handle the case where tags drifted, and decide what to do if the bucket exists but differs from your intent — effectively reimplementing what Terraform’s plan/apply cycle gives you for free.

Convergence and the plan as a contract

Terraform’s plan is the visible proof of idempotency. It shows exactly the delta between desired and actual state before anything is applied. When config and reality agree, the plan is empty; when they diverge, the plan shows only the minimal changes needed to converge back to your declaration.

If you change a value — say, add a tag — the next plan shows a precise, minimal update rather than recreating the resource:

Output:

  # aws_s3_bucket.logs will be updated in-place
  ~ resource "aws_s3_bucket" "logs" {
        id     = "devcraftly-app-logs-2026"
      ~ tags   = {
          + "CostCenter" = "platform"
            # (2 unchanged elements hidden)
        }
    }

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

After this apply, re-running terraform apply once more is again a no-op. Each run drives the system toward, and then holds it at, the declared state — this is convergence.

Beware of resource attributes that change on the provider side every run (for example, a timestamp computed by the API). These cause a “perpetual diff” that breaks idempotency. Use ignore_changes in a lifecycle block to exclude such fields.

resource "aws_instance" "app" {
  ami           = "ami-0abcdef1234567890"
  instance_type = "t3.micro"

  lifecycle {
    ignore_changes = [
      tags["LastScanned"],
    ]
  }
}

This behavior is identical under OpenTofu, which shares Terraform’s declarative engine and the same plan/apply convergence model, so configurations remain idempotent across both tools.

Best Practices

  • Run terraform plan in CI and treat a non-empty plan on an unchanged branch as a signal that drift or a perpetual diff exists.
  • Use ignore_changes for attributes mutated outside Terraform to prevent perpetual diffs that defeat idempotency.
  • Keep all infrastructure under Terraform management; mixing in imperative scripts reintroduces non-idempotent side effects.
  • Prefer declaring desired attributes explicitly rather than relying on provider defaults, so re-runs stay stable across provider upgrades.
  • Verify idempotency after every change by applying twice — the second apply should report 0 added, 0 changed, 0 destroyed.
  • Pin provider versions so refresh and diff behavior stays consistent, keeping no-op runs truly no-op.
Last updated June 14, 2026
Was this helpful?