Terraform HCL Intro 4: Loops with Count and For Each
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:
- count: This is often brought up when talking about looping with Terraform.
- 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:
- We’re using the Terraform length function to set the count value.
- 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 fromlocal.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 withtoset(local.minions)
. Note, we could have also used a variable withtype = set(string)
instead of using thetoset
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. Next post, Terraform HCL Intro 5: Loops with Dynamic Block.
The source code for the examples is available for BoltOps Learn Subscribers: 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
Thanks for reading this far. If you found this article useful, I'd really appreciate it if you share this article so others can find it too! Thanks 😁 Also follow me on Twitter.
Got questions? Check out BoltOps.
You might also like
More tools:
-
Kubes
Kubes: Kubernetes Deployment Tool
Kubes is a Kubernetes Deployment Tool. It builds the docker image, creates the Kubernetes YAML, and runs kubectl apply. It automates the deployment process and saves you precious finger-typing energy.
-
Jets
Jets: The Ruby Serverless Framework
Ruby on Jets allows you to create and deploy serverless services with ease, and to seamlessly glue AWS services together with the most beautiful dynamic language: Ruby. It includes everything you need to build an API and deploy it to AWS Lambda. Jets leverages the power of Ruby to make serverless joyful for everyone.
-
Lono
Lono: The CloudFormation Framework
Building infrastructure-as-code is challenging. Lono makes it much easier and fun. It includes everything you need to manage and deploy infrastructure-as-code.