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.
| Aspect | Declarative (Terraform) | Imperative (shell / CLI) |
|---|---|---|
| You specify | The desired end state | The exact steps to run |
| Re-running | No-op when already converged | Repeats actions; may fail or duplicate |
| Existing-resource handling | Built in via state + refresh | You must code every guard yourself |
| Drift detection | Surfaced automatically in the plan | Not 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_changesin alifecycleblock 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 planin CI and treat a non-empty plan on an unchanged branch as a signal that drift or a perpetual diff exists. - Use
ignore_changesfor 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.