Terraform's sets are an anti-pattern

2024-09-19

druskus

If you have worked with some moderately elaborate Terraform configurations you have likely run into the following error:

│ Error: Invalid for_each argument
│   on auto-turn-off.tf line 149, in resource "aws_iam_role" "scheduler_role":
│  149:   for_each = toset(local.instance_ids)
│     ├────────────────
│     │ local.instance_ids is tuple with 2 elements
│ The "for_each" set includes values derived from resource attributes that cannot be determined until apply, and so Terraform cannot determine the full set of keys that will identify the
│ instances of this resource.
│ When working with unknown values in for_each, it's better to use a map value where the keys are defined statically in your configuration and where only the values contain apply-time
│ results.
│ Alternatively, you could use the -target planning option to first apply only the resources that the for_each value depends on, and then apply a second time to fully converge.

Let's ignore the suggestion of using -target to apply the configuration in two steps. This is almost always worse than splitting the project in two.

The Terraform docs do say: "Do not overcomplicate", and "Do not nest modules too much". Turns out, in a similar fashion to the overly repeated argument against Go's simplicity, sometimes the world is just complicated.

Anyway, the above error occurs because some value, instance_ids in this case, is generated at runtime and Terraform has no way of determining its value until the stack is applied. In other words, for_each requires a static value.

locals {
  instance_ids = [for i in module.ec2_instance : i.instance_id]
}

This is bad. Luckily I found a very simple solution. Instead of using a set, use a map, with statically known keys, and dynamically generated values.

locals {
  instance_ids_map = {for i, val in module.ec2_instance : i => val.instance_id}
}

See, Terraform, does actually know, how many module.ec2_instance items we are creating. It does not need to know their values at planning time, only their keys.

There are other ways to solve this problem, another alternative is to iterate over the length of the set and index the array based on the index. But this is much less clean, and it forces you to use [i] everywhere - or worse, [each.value].

The actual problem is that Terraform recommends the use of toset() and for_each in their documentation, which I think is an anti-pattern.