Terraform for loop

Terraform Stategraph

What is Terraform for loop?

A Terraform for loop takes an input value such as a list, set, tuple, map, or object and produces a new value by transforming each element. It can also filter elements with an optional if clause, but it does not create resources or repeat a resource block. The for_each and count meta-arguments handle multiple instances of infrastructure.

The smallest Terraform for loop

[for env in ["dev", "production"] : upper(env)]

The resulting value is ["DEV", "PRODUCTION"]. Terraform walks the source value, applies upper() to each element, and builds a new value without changing the original list.

{for name, cidr in var.subnets : name => cidr}

This form produces key-value pairs. Curly braces create an object-shaped result, while the expression on the left of => defines each key and the expression on the right defines its value.

What a Terraform for expression actually does

Open a Terraform module written by someone else, and you'll eventually hit a line that looks nothing like the resource blocks around it.

Square brackets, the word for, a colon partway through, no resource keyword in sight. It reads like a loop from a general-purpose programming language dropped into the middle of HCL.

In a sense, that's exactly what it is.

Terraform calls this a for expression, and once you understand what it's doing (producing a new value from an existing one, not repeating an action), the rest of the syntax stops looking strange.

HCL is a declarative language. You describe the infrastructure you want, not the steps to build it, and for most Terraform code, that's accurate.

However, declarative configuration still needs a way to handle data that doesn't arrive in the shape you actually need it in, like a list when you wanted a map, or a set of objects when you wanted just one field pulled out of each.

The for expression exists to bridge that gap without breaking the declarative model. It's still just describing a value. The difference is that the value gets computed from another value rather than written out by hand.

It is exactly the kind of pattern DevOps engineers run into the moment infrastructure code grows past a handful of resources.

A for loop in Terraform takes an existing collection and builds a new tuple or object by applying the same transformation to every element. Terraform can automatically convert those results where a list or map is expected. Nothing gets created on disk, no resource gets provisioned, and there are no side effects.

You feed it a collection on one side, and a value comes out the other. That value is then typically assigned to a local variable, a variable default value, or an output.

The phrase for loop has different connotations in other languages, where a loop is a block of instructions that runs once per item and does something each time, such as printing a line, incrementing a counter, or calling a function.

Terraform's construct works differently enough that its own documentation does not call it a loop at all. It calls it a for expression, an expression that does not perform actions but produces a value.

During planning. Evaluating a for expression during terraform plan does not create EC2 instances, S3 buckets, security groups, an IAM policy, virtual machines, storage accounts, or an auto scaling group. Those objects only appear in a cloud console after a resource block consumes the value and an apply creates them.

Terraform evaluates the collection in a fixed sequence

Three things happen every time a for expression runs, and they happen in a fixed order regardless of how the expression is written.

1. Terraform walks the source collection one element at a time.

2. It evaluates the expression you've given it for that element.

3. It decides whether to keep the result based on an optional condition.

The transformation step does the heavy lifting in most real configurations, turning each source value into a resulting value that is uppercased, lowercased, concatenated with a prefix, or passed straight through unchanged if all you need is the filtering or restructuring elsewhere in the expression.

Filtering is optional, and it is controlled by an if clause at the very end of the expression, letting you filter elements out entirely rather than merely transform values you intend to keep.

Elements that do not satisfy the condition simply do not appear in the output. There is no null placeholder left behind. A list of ten items with an if clause that only three satisfy produces a list with exactly three items, which means the resulting value has fewer elements than the source collection.

The last decision, and the one that trips people up most often, is whether the output is tuple-shaped or object-shaped. Square brackets around a for expression produce a tuple, while curly braces produce an object and require both a key and a value separated by =>. Terraform usually converts those values automatically when the surrounding configuration expects a list or map.

Get the brackets wrong, and Terraform will not give you a warning. Instead, it will try to build the wrong kind of collection and then complain that the resulting value does not match the type you declared.

The syntax of the for expression

Every for expression follows the same skeleton. You open with the bracket that matches your intended output, write for, name the variable (or two variables) used for each element, name the collection you are iterating over, add a colon, and write the expression that produces the result.

List-shaped output

For a list-shaped output, the pattern is small.

[for ITEM in COLLECTION : EXPRESSION]

Map-shaped output

For a map-shaped output, define a key and a value.

{for KEY, VALUE in COLLECTION : KEY => EXPRESSION}

If the source collection is already a list, Terraform can capture both the index and the value while iterating over it.

{for index, value in COLLECTION : index => EXPRESSION}

Adding a filter appends an if clause after the expression and before the closing bracket.

[for ITEM in COLLECTION : EXPRESSION if CONDITION]

The source collection does not have to be a list. Iterating over a map gives you both the key and the value on each pass, which is useful when the transformation depends on the key itself rather than only the value attached to it.

[for key, value in var.some_map : "${key}-${value}"]

Building a map output where two different source elements produce the same key is an error by default. Terraform refuses to silently overwrite one value with another.

Adding three dots after the value expression enables grouping mode, which collects duplicate keys into lists instead of causing a failure.

{for s in var.list : substr(s, 0, 1) => s...}

Without the trailing ..., two strings starting with the same letter produce a key collision, and Terraform stops with an error. With it, every value that maps to the same key is collected in a list under that key.

Practical Terraform for loop examples

The following practical examples show transformation, restructuring, filtering, and grouping without manually defining a second collection.

A list can be transformed without changing the source

Suppose you've got a list of environment names coming in as a variable, and somewhere downstream you need them uppercased for a naming convention.

variable "environments" { type = list(string) default = ["dev", "staging", "production"] } locals { environments_upper = [for env in var.environments : upper(env)] }

local.environments_upper resolves to ["DEV", "STAGING", "PRODUCTION"]. Nothing about the same list changed. The for expression simply produced a new one alongside it.

A list of objects can become a map with stable keys

A more common real-world shape is starting with a list of objects and needing a map keyed by one of their attributes, because for_each requires a map when the elements are not already unique strings.

variable "subnets" { type = list(object({ name = string cidr = string })) default = [ { name = "app", cidr = "10.0.1.0/24" }, { name = "db", cidr = "10.0.2.0/24" }, ] } locals { subnets_by_name = { for s in var.subnets : s.name => s.cidr } }

local.subnets_by_name resolves to a map where app points to 10.0.1.0/24 and db points to 10.0.2.0/24. This exact pattern, reshaping a list of objects into a map keyed by name, is one of the most common ways a for expression gets used because it produces the input value a later for_each meta-argument needs.

The same pattern can combine multiple variables into a local variable containing key-value pairs for security groups, S3 buckets, storage accounts, virtual machines, or an auto scaling group.

The for expression still does not create resources based on that data. A later resource block decides whether to create multiple resources.

An if clause filters elements before they reach the result

Building on the above example, adding conditional logic to the same source list can produce a smaller, more specific output without writing a separate filtering step first.

variable "instances" { type = list(object({ name = string env = string })) default = [ { name = "web-1", env = "production" }, { name = "web-2", env = "staging" }, { name = "web-3", env = "production" }, ] } locals { production_instances = [ for i in var.instances : i.name if i.env == "production" ] }

local.production_instances resolves to ["web-1", "web-3"]. The staging instance never appears in the output. It is not filtered out afterward, it is simply never included in the first place.

The same if clause can filter users with write access, resources for a production environment, or any object whose attributes satisfy the condition.

Grouping mode preserves duplicate keys

One pattern that catches people off guard the first time they hit it is building a map from data where the natural key is not actually unique.

variable "users" { type = list(object({ name = string team = string })) default = [ { name = "alice", team = "platform" }, { name = "bob", team = "platform" }, { name = "carol", team = "data" }, ] } locals { users_by_team = { for u in var.users : u.team => u.name... } }

local.users_by_team resolves to a map where platform points to ["alice", "bob"] and data points to ["carol"].

Without the trailing ..., this exact expression would fail the moment two users shared a team, because Terraform would have nowhere to put the second value once the first had already claimed that key.

for, for_each and count expressions

for_each and count can cause confusion with the for loop because all three looping constructs involve repetition, but they repeat different things.

One of the key benefits of for_each and count is turning a single resource block into multiple resources without copy-pasting the same configuration five times, which is useful when deploying multiple resources, whether they are multiple identical resources or multiple similar resources with unique configurations.

That fan-out is how Terraform reaches the desired state described in a Terraform configuration. One resource block can produce many resulting resources.

Rather than manually defining each resource, you can leverage loops to cover them. A for expression has no opinion on resources at all. It is pure data transformation, and you will often find one feeding into the other because a for expression reshapes a list into the exact map a for_each argument needs.

The clearest way to keep them apart is to ask what is actually being repeated. for_each and count repeat a resource, module, data source, or provider configuration and track each instance separately in state. A for expression repeats nothing at the infrastructure level. It walks a collection once, produces a value, and ends its job.

A for expression frequently produces the exact input value a for_each argument needs, particularly when the data arrives as a list, but for_each needs a map or set. for_each does not produce data that a for expression then consumes. It is usually the point where a value finally becomes infrastructure.

Construct What it produces Use it when
for expression A transformed or filtered value You need to reshape data before another part of the configuration uses it
for_each meta-argument Multiple resource, module, data-source, or provider instances Instances have stable, meaningful keys
count meta-argument A fixed or conditional number of instances Instances are interchangeable or only differ by index

A fixed number of nearly identical resources is why the count meta-argument exists. How count works is simple. Set the count value to a number, and Terraform creates that many instances of the resource block, each one reachable through count.index.

resource "aws_instance" "web" { count = var.instance_count ami = "ami-0c55b159cbfafe1f0" instance_type = "t3.micro" }

If var.instance_count is set to three, there are three EC2 instances, aws_instance.web[0] through to aws_instance.web[2], all sharing the same configuration except for whatever count.index is used to vary.

When running Terraform, the familiar Apply complete! message appears once the apply has finished.

count is often paired with the length() function, using count = length(var.subnets) to size the count value from a source list rather than hardcoding a number. A common reason the count varies is environment. A dev environment might have three instances while a production environment has ten, or the environments may need genuinely different configurations.

Conditionally creating a resource only in certain environments, rather than varying how many instances exist, is often handled with a boolean count value such as count = var.environment == "production" ? 1 : 0. This count loop can conditionally create zero or one instance, while for_each is generally stronger when each instance has a meaningful key.

for_each and count are the constructs used to create multiple resources, while the for expression transforms values – a distinction that is especially significant when you're reading a plan, reasoning about state addresses, or deciding whether identical resources should be indexed or keyed.

A Terraform for loop usually sits inside another value

You'll almost never see a for expression sitting on its own with nothing around it.

It usually lives inside a locals block, where it builds a derived value that other parts of the configuration reference, inside a variable default value, where it reshapes an input before anything else touches it, or inside an output, where it presents a cleaner shape of internal data to whatever is calling the module.

A module that accepts a flexible list of inputs but needs to expose them as a map keyed by name for downstream consumers is a typical case. The module author writes one for expression in an output block, and every caller benefits without needing to know the transformation happened at all.

Wherever it sits, the pattern is the same. Something else needs a differently shaped collection than the one you have, and the for expression gets you from one to the other.

A Terraform for loop usually fails at the collection boundary

Most people make or encounter one of these four mistakes:

  1. Using square brackets when the goal was a map, or curly braces when the goal was a list-shaped value. Terraform often reports the mismatch at the receiving variable or argument, which can make the actual fix less obvious.
  2. Forgetting the trailing ... when two elements produce the same map key. The expression may work in testing with a small sample and only fail once real data introduces a genuine duplicate.
  3. Using a for expression where for_each was the right tool, which produces a value describing five resources rather than creating the five resources themselves.
  4. Assuming an unordered map, object, or set will preserve an arbitrary source order. Terraform applies ordering rules when it converts unordered values into ordered results, so code should not depend on incidental ordering.

Dynamic blocks lean on a closely related idea by generating repeated nested blocks inside a resource based on a collection, but they are a distinct piece of syntax with their own rules. A for expression can prepare the values a dynamic block consumes, but it cannot generate nested blocks by itself.

Stategraph evaluates the resulting value

Expressions like these, especially once they are nested a few levels deep with an if clause and a function call, are exactly the kind of HCL that a parser can misinterpret without anyone noticing until a plan comes back strange.

Stategraph tests its parser against a large corpus of real-world Terraform, so a for expression buried in a locals block resolves to the same value in Stategraph’s graph as it does when Terraform itself evaluates it.

OpenTofu uses the same core expression model

OpenTofu supports the same core for expression syntax described on this page, including transformation, filtering, and grouping. The examples therefore apply to both Terraform and OpenTofu unless a future entry calls out a product-specific difference.

Common questions about Terraform for loops

Does a Terraform for loop create multiple resources?

No. It creates a new value from an existing collection. To create multiple resources or multiple instances from one resource block, use the for_each or count meta-argument.

Can a Terraform for loop use multiple variables?

Yes. The input after in is one collection, but the transformation can reference multiple variables, local values, functions, and attributes that are available in the Terraform configuration. When iterating over a map or list, you can also declare two temporary symbols to capture the key or index and the value.

When should count be used instead of for_each?

Use count when you need a fixed number of multiple identical resources, when instances are interchangeable, or when you need to conditionally create zero or one instance. Use for_each when multiple similar resources need stable keys or unique configurations.

Can a for expression contain conditional logic?

Yes. An if clause filters elements from the result, while a conditional expression inside the transformation can choose between different resulting values. Filtering changes how many elements remain, while conditional transformation changes the value produced for each retained element.

Can a for expression generate dynamic blocks?

No. Use a dynamic block to generate repeated nested configuration blocks. A for expression can transform the source value that the dynamic block iterates over.

The loop name is useful. The expression model is accurate

A for loop in Terraform earns its name through habit more than accuracy.

It builds a new tuple or object from an existing collection, transforming values, optionally filtering them, and choosing its output shape, but it never touches a resource directly.

Once the brackets and the colon read as a request to build a new value from the source rather than an instruction to do something repeatedly, most of what looks unfamiliar about a for expression stops being unfamiliar.

Creating multiple resources belongs to for_each and count, which often consume the kind of map a for expression produces. The for_each, count, map variable, and dynamic block glossary entries cover those adjacent parts of Terraform code.