← Back to blogs

Mastering the Terraform For Loop in 2026

March 28, 2026CloudCops

terraform for loop
terraform for_each
infrastructure as code
devops
terraform
Mastering the Terraform For Loop in 2026

If you’ve ever found yourself copying and pasting a Terraform resource block just to change a name or an IP address, you already know the pain point. Manually defining every cloud resource is not just tedious; it's a direct path to configuration drift and maintenance headaches. A Terraform for loop isn't a single command but a core concept you'll use through expressions like for, for_each, and count. These are the tools that let you build dynamic, scalable infrastructure from lists and maps, cutting down on both code duplication and human error.

Why Iteration Is Your Superpower in Terraform

A superhero orchestrates the iterative management of many cloud databases using a 'for loop' concept.

In the world of Infrastructure as Code (IaC), getting away from the anti-pattern of copy-pasting resource blocks is a huge step toward maturity. Manually duplicating code for every server, user, or S3 bucket is slow and a sure-fire recipe for inconsistent environments and painful updates. This is where iteration becomes a game-changer for any serious DevOps team.

By using Terraform’s looping mechanisms, you turn your static configurations into dynamic, data-driven blueprints. Instead of wrestling with hundreds of lines of nearly identical code, you define one resource block and tell Terraform to iterate over a data structure, creating as many instances as you need. This shift in approach is fundamental to managing infrastructure with real efficiency and precision.

The Core Benefits of Looping in Terraform

Adopting iterative patterns gives you immediate wins and long-term advantages that separate high-performing teams from the rest. The benefits go way beyond just writing less code—they fundamentally improve the quality and reliability of your entire infrastructure.

  • Drastically Reduced Duplication: Write a single resource block to provision dozens or even hundreds of resources. This keeps your codebase DRY (Don't Repeat Yourself) and makes it far easier to read and navigate.
  • Enhanced Maintainability: Need to update a setting across 50 virtual machines? You change one line in your looping resource block, not 50 different ones. Maintenance gets simpler and the risk of inconsistent configurations plummets.
  • Improved Scalability: Adding a new environment or service becomes a simple task of adding an entry to a list or a map, rather than writing entirely new HCL files from scratch.
  • Greater Readability: A well-structured loop is often more intuitive than scrolling through pages of nearly identical resource blocks. It clearly shows the intent: create multiple, similar components.

The impact here isn't trivial. At CloudCops GmbH, we've seen how Terraform's looping constructs have completely changed how teams manage platforms. Since Terraform introduced for_each and improved count in version 0.12, adoption has exploded. By 2023, HashiCorp reported that over 70% of users were using loops in production. This lines up with DORA metrics, where high-performing teams saw a 40% reduction in lead time for changes and a 25% drop in change failure rates. You can find more details in these Terraform loop and conditional findings.

Mastering iteration is a key skill for building robust, automated infrastructure and is a cornerstone of effective DevOps. For a deep dive into integrating security into your development and operations, check out this complete guide to Security for DevOps. You can also see how Terraform stacks up against other tools in our guide on Terraform vs. Ansible.

Practical Looping with Lists and Maps

Alright, you’ve seen the syntax for for expressions. But knowing the syntax is one thing; using it effectively in your day-to-day work is another. for expressions are the engine behind most of your dynamic configurations in Terraform, letting you transform simple lists and maps into real infrastructure.

Let’s skip the theory and get straight to the patterns you’ll actually use.

Iterating Over a List to Create S3 Buckets

This is the bread and butter of Terraform looping. You have a simple list of names and you need to create a resource for each one. It's a pattern you'll use constantly.

Let's say you're spinning up a new service and need a few AWS S3 buckets: one for logs, one for application data, and another for user uploads. You could copy and paste the aws_s3_bucket resource block three times, but that’s a maintenance nightmare. A for loop is the clean way to do it.

First, you'll want to define your bucket names in a variable. This separates your configuration data from your logic, which is always a good practice.

variable "bucket_names" {
  type    = list(string)
  default = ["app-data-storage", "access-logs", "user-uploads"]
}

Now, you use the for_each meta-argument to create the resources. The one catch here is that for_each can't work on a list directly; it needs a set of strings or a map. We can easily fix this by wrapping our variable in the toset() function.

resource "aws_s3_bucket" "standard_buckets" {
  for_each = toset(var.bucket_names)

  bucket = "${each.key}-unique-suffix" # S3 bucket names must be globally unique

  tags = {
    Name      = each.key
    ManagedBy = "Terraform"
  }
}

# Set bucket ACL using a separate resource (required in AWS provider v4+)
resource "aws_s3_bucket_acl" "standard_buckets_acl" {
  for_each = aws_s3_bucket.standard_buckets

  bucket = each.value.id
  acl    = "private"
}

When you iterate over a set of simple strings like this, each.key and each.value are the same—they both hold the current string from the set (e.g., "app-data-storage"). Terraform loops through them, stamping out one S3 bucket for each name. If you need to add a fourth bucket tomorrow, you just add one line to the bucket_names variable. That's it.

Using a Map to Define a Fleet of EC2 Instances

Lists are great for simple resources, but what happens when each resource needs slightly different attributes? This is where maps really shine. Let's say you need to provision a fleet of EC2 instances for different environments, each with a specific instance type. A map is the perfect tool for the job.

With a map, the key becomes a stable, human-readable identifier for your resource, and the value can be an object holding all the specific settings.

variable "ec2_instances" {
  type = map(object({
    instance_type = string
    environment   = string
  }))
  default = {
    "web-prod-01" = {
      instance_type = "t3.medium"
      environment   = "production"
    }
    "api-staging-01" = {
      instance_type = "t3.small"
      environment   = "staging"
    }
    "db-dev-01" = {
      instance_type = "t3.micro"
      environment   = "development"
    }
  }
}

Pro Tip: Using a map with meaningful keys ("web-prod-01") is far more stable than relying on list indices. If you use a count loop on a list and remove an item from the middle, Terraform will see a chain of changes and may try to destroy and recreate unrelated resources. With a map and for_each, the keys give each resource a persistent identity, which is fundamental to writing robust Infrastructure as Code.

Now, creating the AWS EC2 instances is incredibly straightforward. Since our variable is already a map, we can pass it directly to for_each. No conversions needed.

resource "aws_instance" "app_servers" {
  for_each = var.ec2_instances

  ami           = "ami-0c55b159cbfafe1f0" # Example AMI for Amazon Linux 2
  instance_type = each.value.instance_type

  tags = {
    Name        = each.key
    Environment = each.value.environment
  }
}

Here, each.key gives you the instance name (like "web-prod-01"), and each.value gives you the object containing its attributes. So each.value.instance_type pulls the specific instance type for that server.

This pattern is incredibly powerful. You can easily expand the object to include different AMIs, VPC subnets, or security groups for each instance. It all gets driven from a single, easy-to-read map. This is how you build infrastructure that is not just automated, but also self-documenting.

Choosing Between for_each and count

When you're building resources in Terraform, one of the first big decisions you'll face with loops is whether to use for_each or count. They both create multiple resources from a single configuration block, but how they track those resources is fundamentally different. Getting this choice wrong is a classic pitfall that leads to some of the most destructive and unexpected infrastructure changes I've seen.

count is the older of the two. It's simple enough for a basic task: you need a specific number of nearly identical things. Think of it as telling Terraform, "Give me 5 of these." It works by creating a list of resources identified by a numerical index: 0, 1, 2, 3, 4.

That simplicity, however, hides a massive trap, especially when you use count to loop over a list of values.

The Hidden Danger of Count with Lists

Let's say you're creating a few EC2 instances from a list of hostnames: ["server-a", "server-b", "server-c"]. Terraform internally maps these to indices: server-a is 0, server-b is 1, and server-c is 2. All good so far.

But what happens when you need to remove server-b from the middle of that list?

Your list is now ["server-a", "server-c"]. When you run terraform apply, Terraform sees the count has dropped to 2 and compares the new list to its state file:

  • Index 0 is still server-a. No change needed.
  • Index 1 was server-b, but now it's supposed to be server-c. So, Terraform destroys the instance for server-b and creates a brand-new one for server-c at this index.
  • Index 2 (the original server-c) no longer exists in the list, so the original instance tied to that index gets destroyed.

The result is pure chaos. You wanted to remove one server, but Terraform ended up destroying two and recreating one. This re-indexing problem causes unnecessary downtime and makes count a genuinely risky choice for managing any dynamic collection of resources.

Why for_each Is the Modern Standard

This is exactly where for_each comes in as the safer, more predictable solution. Instead of a list and its fragile numerical indices, for_each demands a map or a set of strings. This is the key difference. Each resource is tracked not by its position in a list, but by a stable, meaningful key from the map or set.

Let’s run that same scenario again, but this time using for_each. First, we’d structure our data as a map where the keys are the unique identifiers.

variable "instances" {
  type = map(string)
  default = {
    "server-a" = "t3.micro"
    "server-b" = "t3.small"
    "server-c" = "t3.micro"
  }
}

resource "aws_instance" "app" {
  for_each = var.instances

  instance_type = each.value
  tags = {
    Name = each.key
  }
}

Now, each instance is tied directly to its map key: "server-a", "server-b", and "server-c". If you remove the "server-b" entry from the map, Terraform knows precisely which resource to destroy. It doesn't touch "server-a" or "server-c" because their keys are unchanged. This is the predictable behavior you need for stable infrastructure management and zero-downtime operations.

We can boil this decision down to a simple flowchart based on your input data.

A flowchart illustrating Terraform loop decisions based on data type (list, map) and requirement for index/key.

As the chart shows, the right looping mechanism often comes down to whether your data has stable, unique keys (like a map) or is just an ordered collection (like a list).

The industry has clearly moved to this more stable approach. A 2025 PerfectScale study found that teams using for_each over count report 75% less code repetition and 40% faster provisioning. For our clients at CloudCops GmbH, especially in regulated fields, this kind of stability is non-negotiable. The best practice is clear: for_each is preferred in 68% of advanced configurations because it helps teams avoid an estimated 50% of common state file pitfalls. You can dig deeper into these Terraform loop considerations and their impact.

Advanced Looping Patterns for Complex Infrastructure

Software development lifecycle showing dev, staging, and production servers with monitoring feedback.

Once you get the hang of basic iteration, you can start using loops to solve much more interesting infrastructure problems. This isn't about memorizing complex syntax; it's about learning to combine Terraform's features to model how real-world systems actually work. These are the patterns platform engineers rely on every day to manage messy, demanding environments.

We're going to move past simple lists and maps. You'll see how to wrestle nested data structures into shape, inject conditional logic right into your loops, and generate entire configuration blocks on the fly. This is where a Terraform for loop goes from a convenience to a necessity.

Flattening Data with Nested Loops

A classic problem is dealing with a map where the values are themselves lists. Think about defining a set of applications, where each one needs to be deployed to a different list of environments. A nested data structure is the most logical way to represent this.

Let's start with a variable that maps applications to their target environments and ports.

variable "applications" {
  type = map(object({
    environments = list(string)
    port         = number
  }))
  default = {
    "api-service" = {
      environments = ["dev", "staging", "prod"]
      port         = 8080
    },
    "frontend-app" = {
      environments = ["staging", "prod"]
      port         = 3000
    }
  }
}

The problem is you can't just throw this at for_each to create a resource for every single app-environment pair. First, you have to transform this nested data into a flat structure that for_each can understand. This process is called flattening, and it's a perfect job for a nested for expression inside a locals block.

locals {
  app_environments = flatten([
    for app_name, app_details in var.applications : [
      for env in app_details.environments : {
        key         = "${app_name}-${env}"
        app_name    = app_name
        environment = env
        port        = app_details.port
      }
    ]
  ])
}

Here's what’s happening: the outer loop iterates over the applications map, and the inner loop runs through each app's environments list. This creates a list of lists, which the flatten function then merges into a single, flat list of objects. Each object now cleanly represents one unique deployment target, like api-service-prod.

Conditional Logic Inside Your For Loop

Your infrastructure is rarely uniform. Production gets more resources, development gets less, and some tools only run in certain stages. You can build this logic directly into your for expressions using a simple if clause.

That flattened data we just created is about to become incredibly useful. Let's say you want to create an AWS CloudWatch alarm, but only for production environments.

First, we'll create the primary resources. We need to turn our flat list into a map so for_each can use it.

resource "aws_ecs_service" "main" {
  for_each = {
    for item in local.app_environments : item.key => item
  }

  name            = each.key
  cluster         = "my-ecs-cluster"
  desired_count   = each.value.environment == "prod" ? 3 : 1 # More replicas for prod
  # ... other configuration
}

With the services in place, we can now create the alarms conditionally. The if clause at the end of the for expression acts as a filter, telling Terraform to only run the loop for items that match our condition.

resource "aws_cloudwatch_metric_alarm" "high_cpu" {
  for_each = {
    for item in local.app_environments : item.key => item if item.environment == "prod"
  }

  alarm_name          = "${each.key}-high-cpu"
  metric_name         = "CPUUtilization"
  namespace           = "AWS/ECS"
  statistic           = "Average"
  period              = 300
  threshold           = 80
  comparison_operator = "GreaterThanThreshold"

  dimensions = {
    ClusterName = "my-ecs-cluster"
    ServiceName = aws_ecs_service.main[each.key].name
  }
}

This code is clean, declarative, and easy to follow. It clearly states its intent: "Create a CPU alarm for each production service." That's the kind of self-documenting code you should always aim for. For more on this topic, you might find our guide on adopting Terraform at an enterprise scale useful.

This pattern is incredibly powerful for maintaining compliance and controlling costs. You can use it to enable more expensive, detailed monitoring only where it's mission-critical or to deploy security scanners exclusively in pre-production environments.

Generating Configuration with Dynamic Blocks

Some resource arguments are repeatable configuration blocks themselves, like ingress rules on a security group or environment variables for a container. Defining these manually is tedious and error-prone. A dynamic block lets you use a Terraform for loop to generate these nested blocks programmatically from a list or map.

Imagine you want to define multiple ingress rules for an aws_security_group based on a list of allowed ports.

variable "allowed_ports" {
  type    = list(number)
  default = [80, 443, 8080]
}

resource "aws_security_group" "web_sg" {
  name = "web-server-sg"

  dynamic "ingress" {
    for_each = toset(var.allowed_ports)

    content {
      from_port   = ingress.value
      to_port     = ingress.value
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }
}

In this example, the dynamic "ingress" block iterates over the allowed_ports set. For each port in the set, it stamps out a complete ingress block inside the security group definition, using the port number for both from_port and to_port. This keeps your resource definitions compact and lets you drive configuration from data instead of manual repetition.

Common Terraform Loop Mistakes and How to Fix Them

Every Terraform engineer has been there. You're trying to loop over a list of resources, and suddenly you're staring at a cryptic error message that burns an hour of your day. A misplaced bracket or a wrong assumption about a data type can bring everything to a halt.

Think of this as a field guide to troubleshooting Terraform loops. We're not just going to show you the fix; we’ll dig into why the error happens so you can avoid it in the future. We'll cover the classic mistakes, from type mismatches to the notorious count re-indexing trap, and provide corrected code to get you moving again.

for_each Cannot Be Used on a List

This is the number one mistake I see engineers make, whether they're new to Terraform or have been using it for years. You have a list(string) or a list(object), you feed it to for_each, and Terraform throws an error. It's frustrating, but the reason is fundamental to how Terraform tracks resource state.

for_each needs a map or a set because they provide stable identifiers (keys). Lists don't. Their elements are identified by numerical indices—0, 1, 2...—which are fragile. If you remove an item from the middle of a list, every subsequent item gets a new index, and Terraform sees this as a destructive change.

The fix is always the same: convert your list to a map. A for expression is the perfect tool for the job.

variable "subnets" {
  type = list(object({
    name       = string
    cidr_block = string
  }))
  default = [
    { name = "private-a", cidr_block = "10.0.1.0/24" },
    { name = "private-b", cidr_block = "10.0.2.0/24" }
  ]
}

# INCORRECT - This will fail
# resource "aws_subnet" "bad_example" {
#   for_each = var.subnets
#   ...
# }

# CORRECT - Convert the list to a map
resource "aws_subnet" "good_example" {
  for_each = {
    for subnet in var.subnets : subnet.name => subnet
  }

  vpc_id     = "vpc-12345"
  cidr_block = each.value.cidr_block
  tags = {
    Name = each.key
  }
}

In the corrected code, we iterate over the list and build a map on the fly. We use each subnet's unique name as the key. This gives for_each the stable identifier it needs to manage the resources reliably, even if you add, remove, or reorder subnets in the original list.

Using Count on a List of Changing Values

The second biggest trap is using count to iterate over a list where elements might be removed from the middle. As we just covered, this triggers a re-indexing cascade. Terraform sees that the resource at index 1 is now different and may decide to destroy and recreate a perfectly healthy resource, causing an outage.

Honestly, the solution is almost always to refactor your code to use for_each. It forces you to think about and define a stable key for each resource, which is a best practice that prevents this exact problem. count has its uses, but iterating over a dynamic list of objects isn't one of them.

A Personal Tip: When you're debugging a complex loop, stop staring at the plan output. Fire up the terraform console. You can test your for expressions and inspect your variables and locals directly to see the exact data structure you're producing. This can easily cut your debugging time in half.

Complex Expressions Causing Errors

Sometimes your loop logic itself is the problem. A common issue is accidentally creating duplicate keys when converting a list to a map. If you choose a non-unique attribute for your key, Terraform will reject it.

Another headache comes from dealing with complex or nested data structures. The key here is to use a locals block to break the problem down. Build the data structure you need in locals first. Then, pass that clean, simple local variable to your resource's for_each.

  • Flattening: Use the flatten function to turn a list of lists into a single, flat list.
  • Filtering: Use an if clause inside your for expression to strip out items you don't need.
  • Transforming: Build the final object shape you need right inside the expression before it ever gets to the resource block.

This approach keeps your resource blocks clean and separates your data transformation logic from your resource definitions. You can also enforce these kinds of best practices with policy-as-code. To see how, check out our guide on implementing governance using Open Policy Agent in your infrastructure workflows.

Frequently Asked Questions About Terraform For Loops

Once you get the hang of for expressions, you start running into real-world edge cases. These are the questions we see engineers ask most often when the documentation isn't enough. Think of this as the "what happens when..." guide for Terraform loops.

Can I Use a For Loop to Create Different Types of Resources?

No. A single for_each or count block is designed to create multiple instances of the same resource. For example, you can loop to create ten aws_instance resources, but you can’t use that same loop to create an aws_instance and an aws_s3_bucket.

The right pattern is to use separate resource blocks for each type. Each block gets its own for_each loop that can filter from a shared input variable if needed. This keeps your code clean, declarative, and easy for the next person to read—which is the entire point of using Terraform in the first place.

How Do I Handle Sensitive Values Inside a Terraform For Loop?

When you’re looping over variables that contain secrets like API keys or passwords, you absolutely must mark the input variable with sensitive = true. This is non-negotiable for security.

variable "user_credentials" {
  type = map(object({
    username = string
    password = string
  }))
  sensitive = true
}

Terraform automatically propagates that sensitivity. Any resource or output created from that variable will also be marked as sensitive. This prevents secrets from appearing in plain text in your CLI output and plan logs.

Important: The sensitive flag only redacts values from CLI/UI display (plan output, terminal logs). Sensitive values are still stored in plain text in your Terraform state file. To fully protect secrets, you must also secure your state backend (e.g., use encrypted S3 with restricted access, or Terraform Cloud). For highly sensitive credentials, consider using an external secrets manager like HashiCorp Vault or AWS Secrets Manager and referencing them via data sources instead of storing them directly in variables.

What Is the Difference Between a For Loop in an Output and in a Resource?

The syntax—[for item in collection: expression]—is the same, but the job it’s doing is completely different. It comes down to a distinction between action and information.

  • In a Resource Block: The loop's job is to create infrastructure. Every pass through a for_each or count creates a new resource instance that Terraform manages in its state.
  • In an Output Block: Here, the for expression is all about data transformation. Its only goal is to reshape or filter data into a useful format for a summary. It doesn't create any infrastructure.

For example, you might use a for loop in an output to build a clean map of instance IDs to their private IP addresses. It’s a powerful way to get valuable information about your resources without actually creating anything new.

When Should I Use toset() with for_each?

You have to use the toset() function whenever your for_each input is a simple list of strings, like ["logs", "data", "backups"]. The for_each meta-argument will flat-out reject a list; it only accepts a map or a set of strings.

There’s a very good reason for this. List elements are identified by their index, which is unstable. If you remove an item from the middle of a list, the indices of everything after it shift, causing Terraform to see destructive changes where none were intended. It’s chaos.

toset() solves this by converting the list into a set, where each item is identified by its own unique value. This gives for_each the stable, persistent identifier it needs to track resources reliably.


At CloudCops GmbH, we specialize in building automated, reproducible, and version-controlled infrastructure using an everything-as-code ethos. If you're looking to optimize your cloud platform with expert guidance on Terraform, GitOps, and Kubernetes, explore how our consulting services can accelerate your team's performance. Learn more at https://cloudcops.com.

Ready to scale your cloud infrastructure?

Let's discuss how CloudCops can help you build secure, scalable, and modern DevOps workflows. Schedule a free discovery call today.

Continue Reading

Read Mastering Docker Build Args for Better Container Builds
Cover
Mar 21, 2026

Mastering Docker Build Args for Better Container Builds

Unlock the power of docker build args. This guide shares expert strategies for creating flexible, secure, and blazing-fast container builds.

docker build args
+4
C
Read Mastering The Pipeline In Jenkins For Modern CI/CD
Cover
Mar 18, 2026

Mastering The Pipeline In Jenkins For Modern CI/CD

Discover how a pipeline in Jenkins transforms software delivery. This guide explains Declarative vs. Scripted, Jenkinsfiles, and cloud native workflows.

pipeline in jenkins
+4
C
Read Ansible vs Puppet: ansible vs puppet in 2026
Cover
Mar 15, 2026

Ansible vs Puppet: ansible vs puppet in 2026

ansible vs puppet: a concise 2026 comparison for DevOps teams on architecture, scalability, and ease of use to help you choose.

ansible vs puppet
+4
C