Idempotency in IaC is just an equality check

Idempotency refers to the ability of an operation to be performed multiple times whilst its final effect stays the same as applying it just once.

So in mathematical terms, that would be:

f(f(x)) = f(x)

And why is that so important?

Well, taking the most famous example, imagine you’re making an online payment, and the moment you click Pay your connection drops before you get a confirmation. So did the payment go through? The only safe move for the app is to send the request again. And that’s where the server must remember it already handled the payment, treat the retry as a duplicate, and just return the original result instead of charging you a second time.

So basically, some requests are more expensive than others, so it’s crucial to make sure they only execute once!

In this post, we’re gonna look into how this is implemented in Infrastructure as Code, and we’re gonna be looking at the example of OpenTofu (OSS Terraform).

Idempotency is mostly known in API transactions, how does it apply in IaC?

Infrastructure as Code tools (like Terraform, AWS CloudFormation, OpenTofu) are used to provision resources, mostly in cloud-based environments, using a declarative language (like YAML, HCL, JSON).

If you’ve ever used any of these tools, you’re gonna notice that applying the same configuration multiple times only takes effect once. So let’s unpack what happens.

How does IaC like OpenTofu work

OpenTofu is a tool that translates your configuration items into API calls using the providers’ implementations.

For example, the following block:

resource "aws_s3_bucket" "photos" {
  bucket = "spamsbykarim-photos"
}

would translate into a call that lives in the AWS provider’s repo hashicorp/terraform-provider-aws:line-813, which runs CreateBucket from the AWS SDK for Go.

_, err := tfresource.RetryWhenAWSErrCodeEquals(ctx, d.Timeout(schema.TimeoutCreate), func(ctx context.Context) (any, error) {
		return conn.CreateBucket(ctx, input)
	}, errCodeOperationAborted)

But if that’s all OpenTofu does, running tofu apply twice would call CreateBucket twice, and fail the second time with the bucket already exists error.

So instead tofu apply does the following:

000

First, it makes sure it has the necessary inputs to run, which are:

InputWhat it is
ConfigurationThe .tf files or the desired state
StateRecord of what it created last time
ProvidersPlugins (AWS, etc.) that translate “diff” into real API calls

Apply also refuses to run with no config at all, because that would just mean destroying everything, which tofu forces you to be explicit about by running tofu destroy.

001

The tofu apply command’s entry point is internal/command/apply.go:32 (*ApplyCommand).Run, which basically does the flag parsing, loads the saved plan file if provided, and then builds the actual operation request. If you check the file opentofu/internal/command/apply.go you’ll find that tofu destroy is the same as tofu apply but with Destroy: true.

002

That takes us to internal/backend/local/backend_apply.go:54 opApply; what it does is check if there is a plan, and if not, generate one:

// backend_apply.go:139-143
var plan *plans.Plan
// If we weren't given a plan, then we refresh/plan
if op.PlanFile == nil {
    // Perform the plan
    log.Printf("[INFO] backend/local: apply calling Plan")
    plan, moreDiags = lr.Core.Plan(ctx, lr.Config, lr.InputState, lr.PlanOpts)

Every tofu apply (without a saved plan file) runs a full plan first. Then it checks whether the plan is even worth applying:

// backend_apply.go:160-162
trivialPlan := !plan.CanApply()
hasUI := op.View != nil && op.UIIn != nil
mustConfirm := hasUI && !op.AutoApprove && !trivialPlan

A plan with zero changes is “trivial”; that’s why a second apply doesn’t even ask you for confirmation; there’s nothing to confirm.

003

Those plans are where it all actually happens. For each resource in the configuration, Core.Plan runs three steps:

  1. Refresh, it reads the resource and confirms what it currently looks like (ReadResource).
  2. Merge, it combines what it read (the refreshed state) with your config into a proposed object (objchange.ProposedNew).
  3. Decide, aaaaand this is where we make sure we don’t create the same resources multiple times, in internal/tofu/node_resource_abstract_instance.go:
// Unmark for this test for value equality.
eqV := unmarkedPlannedNewVal.Equals(unmarkedPriorVal)
eq := eqV.IsKnown() && eqV.True()

switch {
case priorVal.IsNull():
	action = plans.Create

case eq && !matchedForceReplace:
	action = plans.NoOp
...
default:
	action = plans.Update
}

004

One last piece: what guarantees the provider is never called on a NoOp? At apply time, each resource hits this guard in internal/tofu/node_resource_apply_instance.go:

// If there is no change, there was nothing to apply, and we don't need to re-write the state, but we do need to re-evaluate postconditions.

if diffApply.Action == plans.NoOp {
	return diags.Append(n.managedResourcePostconditions(ctx, evalCtx, repeatData))
}

It returns before the provider’s ApplyResourceChange is ever reached. So on a second apply, the CreateBucket code we saw at the beginning simply never runs.

So, back to f(f(x)) = f(x)

x is your real infrastructure, and f is one apply run: read, merge, diff, and (maybe) write.

  • First run: no prior state, so Create, so CreateBucket. Now the infrastructure matches your config.
  • Second run: refresh returns the bucket you just created, the merge reproduces it exactly, the equality check passes, everything is NoOp, and zero writes happen.

The effect of f(f(x)) is the same as f(x), even though f does a full run each time.

Good to mention though, OpenTofu can only be as honest as its providers. If a provider misreports values during refresh or plan (the permanent diff bug; I might write a different post about it), the equality check fails on every run and you are forced to Update.