In this post, we’ll cover Terraform looping constructs. Terraform is declarative, so it’s looping structure may seem weird to those used to procedural programming loops. There are a few ways to performing looping with Terraform. We’ll cover the looping constructs that specifically work at the resource level. They handle resource creation itself. There are two of them:

  1. count: This is often brought up when talking about looping with Terraform.
  2. for_each: This technique is similar to the count method but has some advantages. It should be generally used over count. We’ll explain why in this post.

Count Technique: Simple Example

The count technique for looping is commonly brought up due to its simplicity. Here’s an example:

resource "null_resource" "simple" {
  count = 2
}
output "simple" {
  value = null_resource.simple
}

Results in:

$ terraform apply -auto-approve
Outputs:

simple = [
  {
    "id" = "3420691179710313007"
  },
  {
    "id" = "3938693299857881001"
  },
]

It’s pretty easy to grasp. Terraform creates as many of those types of resources as the count value.

Also, note we’re using the null_resource. It’s useful for testing.

Count Technique: List Example

Here’s an example how to achieve a loop that performs some logic with the count technique:

locals {
  names = ["bob", "kevin", "stewart"]
}
resource "null_resource" "names" {
  count = length(local.names)
  triggers = {
    name = local.names[count.index]
  }
}
output "names" {
  value = null_resource.names
}

This results in:

$ terraform apply -auto-approve
Outputs:

names = [
  {
    "id" = "5944721793170602916"
    "triggers" = {
      "name" = "bob"
    }
  },
  {
    "id" = "2105600370698729010"
    "triggers" = {
      "name" = "kevin"
    }
  },
  {
    "id" = "4326674858695036914"
    "triggers" = {
      "name" = "stewart"
    }
  },
]

Let’s point out some things:

  1. We’re using the Terraform length function to set the count value.
  2. We’re using a special count.index variable that Terraform provides. Terrafrom provides this special variable when we use the count technique. It’s magic. We then use this index value to get the name’s actual value from local.names List itself.

Overall, this code creates 3 null_resources with the attributes: bob, kevin, and stewart.

For Each: Simple List Example

Now, let’s redo the last List count example, except with for_each this time.

locals {
  minions = ["bob", "kevin", "stewart"]
}
resource "null_resource" "minions" {
  for_each = toset(local.minions)
  triggers = {
    name = each.value
  }
}
output "minions" {
  value = null_resource.minions
}

Produces:

$ terraform apply
Outputs:

minions = {
  "bob" = {
    "id" = "5967124853326357661"
    "triggers" = {
      "name" = "bob"
    }
  }
  "kevin" = {
    "id" = "7583543865560916708"
    "triggers" = {
      "name" = "kevin"
    }
  }
  "stewart" = {
    "id" = "2631710640638349221"
    "triggers" = {
      "name" = "stewart"
    }
  }
}

We’ve achieved the same looping results with both count and for_each. Some observations:

  • With for_each, we must convert the List type to a Set with toset(local.minions). Note, we could have also used a variable with type = set(string) instead of using the toset function. We’ll discuss the reasons for needing to use a Set type soon.
  • There’s a special each object that is assigned by Terraform. Again, it’s magic. The object has 2 attributes: each.key and .each.value
  • In the case of a Set type, key and value are the same. Generally, recommend sticking to each.value as it feels more natural to understand.

The nice thing about the for_each over the count approach is that we directly access the List values. We no longer have to look it up. IE: local.names[count.index] vs each.value.

For Each Argument Requirements

In the previous example, we pointed out the conversion of the List to a Set with toset(local.minions). If we did not convert it and used this code instead:

resource "null_resource" "minions" {
  for_each = local.minions # instead of toset(local.minions)
  triggers = {
    name = each.value
  }
}

Terraform produces an error like this:

Error: Invalid for_each argument

  on main.tf line 28, in resource "null_resource" "minions":
  28:   for_each = local.minions

The given "for_each" argument value is unsuitable: the "for_each" argument
must be a map, or set of strings, and you have provided a value of type tuple.

Terraform tells us that for_each must be assigned only 1) a map or 2) set of strings.

To help understand why this is the case, let’s take a look at the difference between a Terraform List and a Set.

set = toset(["a", "b", "b"]) # => ["a", "b"]      all elements are unique
list = ["a", "b", "b"]       # => ["a", "b", "b"] the elements don't have to be unique

So the difference between a List and Set is that Set values are all guaranteed to be unique. Also, Sets do not have any particular ordering.

And map structure looks like this:

map = {a = 1, b = 2}

With a map, the key naturally provides uniqueness already.

So Terraform’s for_each type requirement stems from uniqueness. Each element in the iteration needs to have a unique key. Terraform did this by design. It allows us to reference resources by a unique identifier easily.

Let’s look closely at the minions output, which shows all the null_resource items created.

$ terraform apply
Outputs:

minions = {
  "bob" = {
    "id" = "5967124853326357661"
    "triggers" = {
      "name" = "bob"
    }
  }
  "kevin" = {
    "id" = "7583543865560916708"
    "triggers" = {
      "name" = "kevin"
    }
  }
  "stewart" = {
    "id" = "2631710640638349221"
    "triggers" = {
      "name" = "stewart"
    }
  }
}

The resulting resources created with the for_each is a Map. We can tell by the surrounding {...} curly brackets vs the [...] square brackets. This is big difference from when we used count. The count technique resulted in a List. The for_each technique results in a Map.

The resulting object is a Map with unique keys that ties it back to the for_each assignment. This is why for_each can only be assigned a Map or a Set of Strings: uniqueness.

For Each: Map Example

The recommended way to use a for_each loop is with a Map value. It’s a natural fit since we don’t have to do any toset conversion. It also nicely reduces mental overhead.

locals {
  heights = {
    bob     = "short"
    kevin   = "tall"
    stewart = "medium"
  }
}
resource "null_resource" "heights" {
  for_each = local.heights
  triggers = {
    name   = each.key
    height = each.value
  }
}
output "heights" {
  value = null_resource.heights
}

Results in:

$ terraform apply
Outputs:

heights = {
  "bob" = {
    "id" = "3741889141135951736"
    "triggers" = {
      "height" = "short"
      "name" = "bob"
    }
  }
  "kevin" = {
    "id" = "4411119930138579807"
    "triggers" = {
      "height" = "tall"
      "name" = "kevin"
    }
  }
  "stewart" = {
    "id" = "2041734924508225405"
    "triggers" = {
      "height" = "medium"
      "name" = "stewart"
    }
  }
}

The usage of both each.key and each.value is natural and easier to understand.

The For Each Advantage

At the beginning, we mentioned that for_each should generally be used over count because it provides an advantage. That advantage has to do with what happens when we update the code.

The count Problem

Let’s say we have a count loop that creates 2 resources: bob and stewart.

locals {
  names = ["bob", "stewart"]
}
resource "null_resource" "names" {
  count = length(local.names)
  triggers = {
    name = local.names[count.index]
  }
}

First, we apply and create the resources. Next, we add kevin to the list of names.

locals {
  names = ["bob", "kevin", "stewart"]
}

Then, when we apply again, we expect that only kevin gets added and nothing else is affected. However, something else unexpected happens:

$ terraform plan
Terraform will perform the following actions:

  # null_resource.names[1] must be replaced
-/+ resource "null_resource" "names" {
      ~ id       = "756389024251013718" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "name" = "stewart" -> "kevin"
        }
    }

  # null_resource.names[2] will be created
  + resource "null_resource" "names" {
      + id       = (known after apply)
      + triggers = {
          + "name" = "stewart"
        }
    }

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

Instead of just adding kevin and leaving the current resources untouched, Terraform wants to replace stewart with kevin and then recreate stewart again. This is because the index for stewart has changed. The key learning point is that stewart’s unique identifier is associated with the List index.

names = ["bob", "stewart"]           # stewart index was 1
names = ["bob", "kevin", "stewart"]  # stewart index is now 2

Luckily, this is only a null_resource for testing, so there’s no harm done here. If this was a database or an EC2 instance, then the resource would be deleted and recreated. Putting it kindly, it’s undesirable behavior.

The for_each Solution

The for_each technique avoids this deletion behavior since the resource unique identifier remains the same. For example if we start with:

locals {
  heights = {
    bob     = "short"
    stewart = "medium"
  }
}
resource "null_resource" "heights" {
  for_each = local.heights
  triggers = {
    name   = each.key
    height = each.value
  }
}
output "heights" {
  value = null_resource.heights
}

We apply and create the resources. Then we add kevin to the heights Map.

locals {
  heights = {
    bob     = "short"
    kevin   = "tall"
    stewart = "medium"
  }
}

The plan looks like this:

$ terraform plan
Terraform will perform the following actions:

  # null_resource.heights["kevin"] will be created
  + resource "null_resource" "heights" {
      + id       = (known after apply)
      + triggers = {
          + "height" = "tall"
          + "name"   = "kevin"
        }
    }

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

This is a significant improvement. The kevin null_resource will be added, and everything else left is untouched.

Summary

In this post, we covered 2 Terraform looping constructs: count and for_each. We provided some examples and explained why generally, you should prefer the for_each technique over the count approach. In these introductory examples, we assign only one attribute value to the resource for each iteration of the loop. Next, we’ll cover how to assign multiple attributes per iteration. We’ll also cover the dynamic nested block looping construct.

The source code for the examples is available at: terraform-hcl-tutorials/4-loops-count-for-each

Want It to be Easier to Work with Terraform?

Check out Terraspace: The Terraform Framework.

The Terraform HCL Language Intro Tutorials