Terraform for_each: A simple Tutorial with Examples
Congratulations! You recently joined an SRE team that uses Terraform to provision and manage infrastructure components. After some time, you notice that they configured a couple of similar infrastructure resources using the count
meta-argument as seen in the code snippet below:
# variables.tf
variable "ami" {
type = string
default = "ami-0078ef784b6fa1ba4"
}
variable "instance_type" {
type = string
default = "t2.micro"
}
variable "sandboxes" {
type = list(string)
default = ["sandbox_one", "sandbox_two", "sandbox_three"]
}
# main.tf
resource "aws_instance" "sandbox" {
ami = var.ami
instance_type = var.instance_type
count = length(var.sandboxes)
tags = {
Name = var.sandboxes[count.index]
}
}
After going through the existing terraform configuration files, you identified some inefficient code and proposed some code changes. The first batch of this code refactor includes using the for_each
instead of the count
meta-argument.
But what issues does the count
meta-argument really pose since it achieved the desired end result? Why was the for_each
meta-argument introduced by Terraform? Let’s first understand what meta-arguments are in Terraform, then dive into why for_each
exists.
Key Takeaways
for_each
is commonly used to provision multiple similar infrastructure resources in Terraform. It prevents unintended remote object changes that come with using the count meta-argument.- The
for_each
meta-argument can be used in resource, data, or module blocks. Also, when working withfor_each
in these blocks, there are some important rules to follow e.g.,for_each
only accept values that are sets or maps. - The
for_each
andcount
meta-arguments cannot be used in the same block, as both are used to create multiple similar infrastructure objects in Terraform.
Try the Terraform Count For Each Lab for free
What are Meta-arguments in Terraform?
Meta-arguments are simply terraform’s special arguments used in the resource, data, or module blocks to customize the behavior of these blocks. Each of these block types has its set of meta-arguments that it supports. For example, the resource block in Terraform supports the count meta-argument, as seen earlier above. It also supports the for_each
meta-argument and some others.
Want to learn more about Terraform's basic concepts? Check out this video:
By default, a resource block in Terraform creates only one infrastructure resource, which could be a storage bucket, load balancer, compute instance, virtual network, etc.
But what if you want to provision more than one infrastructure resource of the same resource type? This is where meta-arguments like count
and for_each
come in. They are the two ways of creating multiple instances of an infrastructure resource in Terraform. For example, instead of typing an individual resource block for every instance of an infrastructure resource you want to create, you can instead type a single resource block and then use a meta-argument like count
or for_each
to create multiple instances of any infrastructure resource as desired.
Using the count
meta-argument in certain ways, like it was done above, is not recommended. Let’s see why in the next section.
The Problem with the Count Meta-argument
Lists are ordered collections which means that the order of every element within a list is significant. The count
meta_argument works with a list type, meaning every element has an index position - as seen below - when you run the terraform state list
command to list all resources in the terraform.tfstate file:
From above, every element is referenced using an index number, and not even the string values in the sandboxes variable in the code snippet seen earlier, i.e., default = ["sandbox_one", "sandbox_two", "sandbox_three"]
. If you remove any element that is not the last element in the list above, you would get unexpected infrastructure resource changes when you run terraform plan
to see the execution plan.
For example, if you remove the element at index 0 (i.e., sandbox_one), the following happens:
- The current element at index 1 (i.e., sanbox_two) will now become the new element at index 0, i.e., sandbox_two is now at index 0
- The current element at index 2 (i.e., sanbox_three) will now be the new element at index 1, i.e., sandbox_three is now at index 1
- There will be no element at index 3, and this index will be destroyed
This is where the flexibility of the for_each
meta-argument comes into play. Since for_each
works with unordered collections like a map or set, elements are instead referenced by string values. Let’s dive deeper into the for_each
meta-argument in the following sections.
The for_each meta-argument
The for_each
meta-argument is just another way to create multiple similar instances of an infrastructure resource in a more flexible way. It can be used in a resource, data, or module block. It works with a map or a set of strings, and it creates an instance for each item in a map or set. Let’s look at some examples of how for_each
is used below.
Examples using the for_each meta-argument
As mentioned earlier, for_each
can be used in the resource, data, or module blocks. The examples below demonstrate its usage:
Using for_each in resource blocks
Let’s start by refactoring the code snippet with the count
meta-argument that we saw earlier:
# variables.tf
variable "ami" {
type = string
default = "ami-0078ef784b6fa1ba4"
}
variable "instance_type" {
type = string
default = "t2.micro"
}
variable "sandboxes" {
type = list(string)
default = ["sandbox_one", "sandbox_two", "sandbox_three"]
}
# main.tf
resource "aws_instance" "sandbox" {
ami = var.ami
instance_type = var.instance_type
for_each = toset(var.sandboxes)
tags = {
Name = each.value # for a set, each.value and each.key is the same
}
}
Since for_each
works with either a set or map of strings, we can just do a type conversion using the built-in terraform function, toset
. It takes a list type and converts it to a set. A set is an unordered collection of unique values.
The for_each
meta-argument used in any block type also comes with the each
object available. The each
object is used to customize the configuration of each similar resource. This object also comes with 2 properties:
each.key
for a set is the values of a set. For a map, it is the map’s key, e.g.{map_key: “map_value” }
each.value
for a set is the same aseach.key
. For a map, it is the associated value for the key.
In the code above, for the first iteration, each.value
will be sandbox_one, for the next iteration, it will be sandbox_two, and then sandbox_three. It will create 3 AWS EC2 instances tagged with the names sandbox_one, sandbox_two, and sandbox_three.
We can use Terraform set type to refactor it further, as seen below:
# variables.tf
variable "ami" {
type = string
default = "ami-0078ef784b6fa1ba4"
}
variable "instance_type" {
type = string
default = "t2.micro"
}
variable "sandboxes" {
type = set(string)
default = ["sandbox_one", "sandbox_two", "sandbox_three"]
}
# main.tf
resource "aws_instance" "sandbox" {
ami = var.ami
instance_type = var.instance_type
for_each = var.sandboxes
tags = {
Name = each.value # for a set, each.value and each.key is the same
}
}
As you can see above, since the variable sandboxes
is now a set type, there is no need for type conversion. Also, note that each instance created by for_each
is a map of objects.
Now, let’s see another example that uses the map type:
# variables.tf
variable "ami" {
type = string
default = "ami-0078ef784b6fa1ba4"
}
variable "sandboxes" {
type = map(object({
instance_type = string,
tags = map(string)
}))
default = {
sandbox_one = {
instance_type = "t2.small"
tags = {
Name = "sandbox_one"
}
},
sandbox_two = {
instance_type = "t2.micro"
tags = {
Name = "sandbox_two"
}
},
sandbox_three = {
instance_type = "t2.nano"
tags = {
Name = "sandbox_three"
}
}
}
}
# main.tf
resource "aws_instance" "sandbox" {
ami = var.ami
for_each = var.sandboxes
instance_type = each.value["instance_type"]
tags = each.value["tags"]
}
A map type in Terraform is a type constructor, which allows you to build up complex types. It is of the form map(<TYPE>), where TYPE can be a type constraint like a string or even a type constructor like an object. The above example uses a map of objects to create multiple AWS EC2 instances.
Each instance (i.e., sanbox_one, sandbox_two, and sandbox_three) above consists of a string and a map of string as values in the map seen above. The object, each.value
, in the resource block references the map keys, i.e., instance_type
and tags
.
The resource block can also be written as:
# main.tf
resource "aws_instance" "sandbox" {
ami = var.ami
for_each = var.sandboxes
instance_type = each.value.instance_type
tags = each.value.tags
}
It is just another way to reference the map keys using the dot notation.
Please note that the arguments used in any resource block vary based on the provider. AWS is the provider used in the examples above.
Using for_each in data blocks
Let’s see an example of how for_each
is used in a data block:
# variables.tf
variable "instance_ids" {
type = set(string)
default = ["i-0c394b14ded6e401f", "i-02a4984badba201cb", "i-0c791c2e2e0f6f0b3"]
}
variable "drive_size" {
type = number
default = 6
}
# main.tf
data "aws_instance" "test_server" {
for_each = var.instance_ids
instance_id = each.value
filter {
name = "tag:Environment"
values = ["test"]
}
filter {
name = "instance-state-name"
values = ["running"]
}
}
resource "aws_ebs_volume" "test_server_drive" {
for_each = data.aws_instance.test_server
availability_zone = data.aws_instance.test_server[each.key].availability_zone
size = var.drive_size
}
resource "aws_volume_attachment" "test_server_drive_attach" {
for_each = data.aws_instance.test_server
device_name = "/dev/sdb"
volume_id = aws_ebs_volume.test_server_drive[each.key].id
instance_id = data.aws_instance.test_server[each.key].id
}
In the code above, the data block (i.e. aws_instance.test_server) fetches information about AWS EC2 instances based on the specified requirements (i.e. instance_id, tag, and the instance state). Using for_each
, it fetches all EC2 instances that have the specified instance id in the instance_ids set variable. Also, the instances fetched should have the Environment:test tag, and they should all be in a running state.
Then, in the resource block (i.e., aws_ebs_volume.test_server_drive), the data source (i.e., aws_instance.test_server) is referenced in the for_each
expression. In Terraform, resources using for_each
are represented as a map of objects. So, data.aws_instance.test_server is actually a map of objects, where each object is an instance fetched by the data block. Hence, data.aws_instance.test_server is represented as:
{
test_server_1 = {
id = "i-0c394b14ded6e401f"
availability_zone = "ca-central-1"
// other data resource attributes
},
test_server_2 = {
id = "i-02a4984badba201cb"
availability_zone = "ca-central-1"
// other data resource attributes
},
test_server_3 = {
instance_type = "i-0c791c2e2e0f6f0b3"
availability_zone = "ca-central-1"
// other data resource attributes
}
}
So, an aws_ebs_volume.test_server_drive volume resource is created for every data.aws_instance.test_server instance fetched. In the same vein, data.aws_instance.test_server[each.key].availability_zone gets the availability zone for each EC2 instance fetched by the data source block. Since data.aws_instance.test_server is a map of objects, each.key
refers to the map key (e.g., test_server_1), and you can now get any attribute of the instance using the dot notation (e.g., .availability_zone).
The same concept also applies in the aws_volume_attachment.test_server_drive_attach resource block above. For each EC2 instance, an AWS EBS volume attachment is created. It also specifies the EC2 instance (i.e., instance_id) and EBS volume (i.e., volume_id) for the attachment using the each.key
object attribute.
Please note that in the data block example given above, there is a one-to-one relationship between these objects (e.g., data.aws_instance.test_server and aws_ebs_volume.test_server_drive), and as a result, you can use one resource as the for_each of another. This is referred to as Chaining for_each Between Resources.
Using for_each in module blocks
Now, let’s see an example of how for_each
can be used in a module block:
# child module - variables.tf
variable "instance_type" {
type = string
default = "t2.micro"
}
# child module - ec2_instances.tf
resource "aws_instance" "qa" {
ami = "ami-0078ef784b6fa1ba4"
instance_type = var.instance_type
tags = {
Environment = "qa"
}
}
# root module - locals.tf
locals {
qa_instances = {
"qa_server_1" = { instance_type = "t2.small" },
"qa_server_2" = { instance_type = "t2.micro" },
"qa_server_3" = { instance_type = "t2.nano" },
}
}
# root module - main.tf
module "virtual_servers" {
source = "./modules/virtual_servers"
for_each = local.qa_instances
instance_type = each.value.instance_type
}
In Terraform, modules are used to abstract implementation configurations of infrastructure objects to be provisioned. They help with infrastructure object reusability by organizing these resources into small and manageable components. A module is basically a directory with terraform configuration files. A root module is a module that calls the child module.
In the example above, the virtual_servers module is reused to create multiple virtual ec2 instances using the for_each
meta-argument. For each EC2 instance specified in the local value (i.e., qa_instances), a virtual server is created. Also, each.value
is used to assign an instance_type for each EC2 instance in each iteration.
Please note that the above example uses a local module. You can also use modules in Terraform’s public registry.
So far, we have seen examples of how for_each
can be used in a resource, data, or module block. for_each
also works with a special kind of block called dynamic blocks. Let’s take a look at an example in the next section.
For_each with dynamic blocks
In terraform, dynamic blocks are commonly used to construct repetitive nested blocks without duplicating code. The for_each
meta-argument is commonly used in a dynamic block to achieve the desired result. See the code snippet below:
resource "aws_security_group" "test_sg" {
name = "test_sg"
vpc_id = var.vpc_id
dynamic "ingress" {
for_each = var.ingress_ports
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "Allows ingress for port ${ingress.value}"
}
}
}
In the code snippet above, we are creating an AWS security group resource that will allow ingress traffic into the VPC specified with the vpc_id variable. So, instead of duplicating the nested ingress block, a dynamic block in the resource block can dynamically construct repeatable ingress blocks using for_each
. In a dynamic block, the name of the block (e.g., ingress in the example above) is used instead of each. So, using for_each
with a dynamic block, you can easily create multiple nested blocks.
Please note that dynamic blocks in Terraform can also be used inside other block types, which include data, provider, and provisioner blocks.
Key considerations when working with the for_each meta-argument
We have seen several examples of how the for_each
meta-argument can be used. Below are some things to keep in mind when working with for_each
.
Referencing blocks and block instances
Blocks that use the for_each
meta-argument (i.e., resource, data, and module blocks) create multiple infrastructure instances. So, how does terraform refer to each of these instances and also the block itself? Let’s see how.
Referring to the block: use <block_type>.<block_name>. Below are examples of references for block types.
- Resource block: <resource_type>.<resource_name> e.g. aws_instance.test_server
- Data block: data.<resource_type>.<resource_name> e.g. data.aws_ebs_volume.test_server_drive
- Module block: module.<module_name> e.g. module.virtual_servers
Referring to the instance: use <block_type>.<block_name>[<key>]. Below are examples of references for instances that use for_each
.
- Resource block: <resource_type>.<resource_name>[<key>] e.g. aws_ebs_volume.test_server_drive[“test_server_drive_1”]. In the earlier section, “Using for_each in data blocks”, we saw an expression like aws_ebs_volume.test_server_drive[each.key].id - The each.key attribute here references each key of the map object (i.e., a map of AWS EBS volumes created for each EC2 instance) used in for_each expression in that block.
- Data block: data.<resource_type>.<resource_name>[<key>] e.g. data.aws_instance.test_server[“test_server_1”]. An example is also seen in the “Using for_each in data blocks” section.
- Module block: module.<module_name>[<key>] e.g. module.virtual_servers[“test_server_1”]
For a module that creates multiple instances of a specified resource, the instances are prefixed with the module.<module_name>[<key>] in the Terraform UI. Below are trimmed-down versions of the terraform plan
output for the module used in the “Using for_each in module blocks” section:
Limitations with for_each
for_each
keys (i.e., the keys of a map) or values (i.e., the values in a set) which are used for iteration serve as identifiers for the multiple resources they create. As such, they are always visible in the terraform UI output (i.e., terraform plan
or terraform apply
steps) and also in the state file. Hence, sensitive values cannot be used as arguments in for_each
implementations. The sensitive values that can’t be used include:
- Sensitive input variables: variables with the sensitive argument set to true.
- Sensitive outputs: outputs with the sensitive argument set to true.
- Sensitive resource attributes: attributes with sensitive information marked as sensitive using the built-in sensitive function.
You will get an error if you try using sensitive values as for_each arguments.
Also, the keys or values of a for_each
have to be known before a terraform apply
operation. These keys or values cannot also depend on the result of any impure function like timestamp because they are evaluated later on during the main evaluation step. Also, use descriptive keys or values. Since for_each
values identify resources, using meaningful keys or values helps to easily identify resources.
Expressions with for_each
The types of values used with for_each
have to be a set or a map. If you have a list of values, you can use the toset
type conversion function to convert it to a set, as seen in the “Using for_each in resource blocks” section.
Also, you may have nested data structures that are not a suitable value to work with for_each
. You can use the for construct or any helpful built-in function like flatten to build up a suitable value for the for_each
meta-argument. See example code snippets below:
# variables.tf
variable "sandboxes" {
type = list(object({
name = string
instance_type = string
}))
default = [
{
name = "sandbox_1"
instance_type = "t2.small"
},
{
name = "sandbox_2"
instance_type = "t2.micro"
},
{
name = "sandbox_3"
instance_type = "t2.nano"
},
]
}
# locals.tf
locals {
flat_sandboxes = {
for sandbox in var.sandboxes :
sandbox.name => sandbox
}
}
# main.tf
resource "aws_instance" "example" {
for_each = local.flat_sandboxes
instance_type = each.value.instance_type
ami = var.ami
tags = {
Name = each.value.name
}
}
The code snippet above shows how to use the for
construct to prepare a suitable value for the for_each
meta-argument. The locals
block in terraform is used to assign a name to an expression for reuse as many times as desired. See how local.flat_sandboxes looks like:
As you can see, flat_sandboxes is a map of objects which is what is expected by the for_each
meta-argument.
Please also note that Terraform does not support nested loops in the resource block. You can only use one for_each
meta-argument, and it cannot be nested. Hence, the use of a local block above for the nested loop operation.
Chaining for_each
As seen in the “Using for_each in data blocks” section, you can use one resource as the for_each
of another when there is a one-to-one relationship between these objects. Since an AWS EC2 instance is commonly associated with an AWS EBS volume for storage, if you provision multiple EC2 instances using for_each
, then you can chain that for_each
into another resource to create an EBS for every EC2 instance.
Now, you might be thinking and scratching your head, saying, I thought you said that the keys or values of a for_each
must be known before a terraform apply
.
Remember the example given earlier in the “Using for_each in data blocks” section? The for_each
expression used in the aws_ebs_volume.test_server.drive block references data.aws_instances.test_server, which also references var.instance_ids in the data block. The var.instance_ids variable has known values that the data block uses to retrieve already existing EC2 instances, and then the resource block uses these EC2 instances in the for_each
expression to create an EBS volume per EC2 instance.
Performance considerations when using for_each
Below are some best practices to adhere to when working with for_each
:
- Use
for_each
cautiously as it can impact performance. This is becausefor_each
increases the number of API calls made to a provider for every infrastructure object provisioned.
Test your Terraform mastery with our free Terraform Challenges. They will help in polishing your infrastructure provisioning and management skills!
FAQ
Below are some of the frequently asked questions about for_each
.
When should I use for_each instead of count?
for_each
is commonly used when you need to create similar infrastructure objects that have distinct values. Also, if you do not want the unintended changes that come with using count when modifying your infrastructure object.
If your resource instances are identical and not impacted by the unintended changes that count
causes during modification, then you can use the count
meta-argument.
Can you use both for_each and count meta-arguments in the same block?
Since both serve a similar purpose which is to create multiple instances of a resource, you can only use one of them. You cannot use both in the same resource block.
Conclusion
You have learned why Terraform introduced for_each
and also seen several examples of how for_each
can be used. Even though for_each
gives you flexibility; you need to be mindful when using it, especially in large-scale deployments, to avoid performance degradation, as highlighted earlier.
Are you looking to polish your Terraform skills in a real-world environment? Enroll in our Terraform for Beginners Course, which covers all of Terraform fundamentals. It includes video lectures, interactive exercises, and hands-on labs to help you internalize concepts and commands.
If you want to gain other Infrastructure as Code (IaC) skills, check out our IaC Learning Path.