Skip to content
ioob.dev
Go back

Terraform Part 10 — Loops and Conditionals

· 4 min read
Terraform Series (10/15)
  1. Terraform Part 1 — What Is Terraform
  2. Terraform Part 2 — Installation and First Deploy
  3. Terraform Part 3 — HCL Syntax
  4. Terraform Part 4 — Variables and Outputs
  5. Terraform Part 5 — Providers
  6. Terraform Part 6 — Resources and Dependencies
  7. Terraform Part 7 — Data Sources and Import
  8. Terraform Part 8 — State Management
  9. Terraform Part 9 — Modules
  10. Terraform Part 10 — Loops and Conditionals
  11. Terraform Part 11 — Workspaces and Environment Separation
  12. Terraform Part 12 — Kubernetes and Helm Providers
  13. Terraform Part 13 — CI/CD Integration
  14. Terraform Part 14 — Testing and Policy
  15. Terraform Part 15 — Practical Patterns and Pitfalls
Table of contents

Table of contents

Creating the Same Resource Multiple Times

Three subnets, five security group rules, ten IAM users. When writing infrastructure code, there are plenty of resources you need to create repeatedly. You could copy-paste, but Terraform provides a more elegant approach.

There are two main meta-arguments for iteration: count and for_each. Knowing which to use in which situation is the core of this part.

flowchart LR
    Need["Need iteration"] --> Q{"Is there a\nunique key to\nidentify items?"}
    Q -->|"No (just a count)"| Count["Use count"]
    Q -->|"Yes (names, IDs, etc.)"| ForEach["Use for_each"]

    Count -.-> CountCase["e.g., 3 identical EC2s"]
    ForEach -.-> ForEachCase["e.g., per-user IAM,\nper-region S3 buckets"]

count — Iterate by Number

count is the simplest form of iteration. Give it a number, and it creates that many resources.

resource "aws_instance" "worker" {
  count = 3

  ami           = "ami-0c9c942bd7bf113a2"
  instance_type = "t3.micro"

  tags = {
    Name = "worker-${count.index}"
  }
}

count.index references the current iteration number (starting from 0). The code above creates three instances: worker-0, worker-1, worker-2.

References also use indices.

resource "aws_eip" "worker_ip" {
  count    = 3
  instance = aws_instance.worker[count.index].id
}

# Reference the first instance from elsewhere
output "first_worker_ip" {
  value = aws_instance.worker[0].private_ip
}

# Reference all (splat)
output "all_worker_ips" {
  value = aws_instance.worker[*].private_ip
}

[*] is the splat expression. It extracts a specific attribute from all instances as a list.

The Pitfall of count

count is convenient but has one critical pitfall. Removing a middle item causes everything after it to be recreated.

# Initially: 3 subnets
resource "aws_subnet" "app" {
  count      = 3
  cidr_block = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"][count.index]
}

# Want to remove the middle CIDR
# cidr_block = ["10.0.1.0/24", "10.0.3.0/24"]
# count = 2

When you make this change, from Terraform’s perspective, index 1 (previously 10.0.2.0/24) becomes 10.0.3.0/24, and index 2 disappears. The actual intent was “remove the middle one,” but Terraform recreates two resources. If these are production subnets, it’s catastrophic.

count is only safe to use when all instances are completely identical and you’ll never need to remove one from the middle. It’s fine if you’re only ever adding instances, never removing them.

for_each — Iterate by Key

Because of these limitations, most production code uses for_each. Each iteration instance gets a unique key, so items with the same key are recognized as the same instance.

for_each accepts a map or set.

Iterating with a map

resource "aws_subnet" "app" {
  for_each = {
    a = "10.0.1.0/24"
    b = "10.0.2.0/24"
    c = "10.0.3.0/24"
  }

  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value
  availability_zone = "ap-northeast-2${each.key}"

  tags = {
    Name = "subnet-${each.key}"
  }
}

each.key is the map key (“a”, “b”, “c”), and each.value is the value (“10.0.1.0/24”, etc.).

Even if you remove b from the middle, a and c remain untouched. Terraform manages them as aws_subnet.app["a"], aws_subnet.app["b"], aws_subnet.app["c"], and when b is removed, only that one is deleted.

# Remove b
for_each = {
  a = "10.0.1.0/24"
  c = "10.0.3.0/24"  # c keeps its key
}
# → Only b is deleted, a and c are untouched

Iterating with a set

When values don’t differ individually and you just have a simple list of names, a set is convenient.

variable "user_names" {
  default = ["alice", "bob", "carol"]
}

resource "aws_iam_user" "developer" {
  for_each = toset(var.user_names)
  name     = each.value
}

Wrapping a list with toset() turns it into a set. Sets don’t allow duplicates, and each item serves as its own key. each.key and each.value are the same value.

Reference Patterns

count uses index-based references, while for_each uses key-based references.

# Referencing for_each-created resources
resource "aws_route_table_association" "app" {
  for_each = aws_subnet.app

  subnet_id      = each.value.id
  route_table_id = aws_route_table.main.id
}

# Access by specific key
output "zone_a_subnet_id" {
  value = aws_subnet.app["a"].id
}

# Access as a full map
output "all_subnet_ids" {
  value = { for k, v in aws_subnet.app : k => v.id }
}

count vs for_each — Selection Criteria

The choice between these two meta-arguments boils down to “will I need to add or remove items later?”

SituationRecommendation
Items are completely identical, only the count matters (e.g., N identical workers)count
Items have different configurations (e.g., per-environment, per-user)for_each
Might need to remove items from the middlefor_each
Want to conditionally create 0 or 1 instancescount = var.enabled ? 1 : 0

Using count for conditional creation is an idiomatic pattern.

resource "aws_instance" "bastion" {
  count = var.create_bastion ? 1 : 0

  ami           = "ami-xxx"
  instance_type = "t3.nano"
}

output "bastion_ip" {
  value = var.create_bastion ? aws_instance.bastion[0].public_ip : null
}

In cases like this, count is more natural than for_each.

dynamic Blocks — Iterating Nested Blocks

Sometimes you need to repeatedly create nested blocks within a resource. Security group ingress rules are a classic example.

# Writing statically
resource "aws_security_group" "web" {
  name = "web-sg"

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/8"]
  }
}

When there are many rules, it gets repetitive. Using a dynamic block lets you create rules dynamically from data.

variable "ingress_rules" {
  type = list(object({
    from_port   = number
    to_port     = number
    protocol    = string
    cidr_blocks = list(string)
  }))

  default = [
    { from_port = 80,  to_port = 80,  protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] },
    { from_port = 443, to_port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] },
    { from_port = 22,  to_port = 22,  protocol = "tcp", cidr_blocks = ["10.0.0.0/8"] },
  ]
}

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

  dynamic "ingress" {
    for_each = var.ingress_rules

    content {
      from_port   = ingress.value.from_port
      to_port     = ingress.value.to_port
      protocol    = ingress.value.protocol
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}

dynamic "ingress" means “dynamically generate nested blocks named ingress.” The contents of a single block go inside content, and ingress.value references the current iteration item.

Avoid Overusing dynamic

dynamic is powerful, but using it more than necessary hurts readability. If there are only about 3 rules, writing them explicitly is more readable. Use it only when rules change dynamically or when there are many.

flowchart TB
    Start["Repeat nested blocks?"] --> Few{"Few and\nfixed?"}
    Few -->|"Yes (2-3)"| Plain["Just write them out"]
    Few -->|"No"| Dyn["Use dynamic"]

    Plain -.-> Reason1["Easy to read"]
    Dyn -.-> Reason2["Data-driven\nflexible management"]

Conditional Expressions

Terraform’s conditional expressions use ternary operator syntax.

locals {
  instance_type = var.environment == "prod" ? "m5.large" : "t3.micro"

  # Nesting is possible (but readability suffers)
  size = var.env == "prod" ? "large" : var.env == "staging" ? "medium" : "small"
}

When nesting gets deep, using lookup() or a map is better.

locals {
  instance_sizes = {
    dev     = "t3.micro"
    staging = "t3.medium"
    prod    = "m5.large"
  }

  instance_type = local.instance_sizes[var.environment]
}

for Expressions — List/Map Transformations

The most frequently used powerful feature. Transform lists to maps, maps to lists, and filter — all in a single line.

List to List (transformation)

variable "names" {
  default = ["alice", "bob", "carol"]
}

locals {
  upper_names = [for name in var.names : upper(name)]
  # ["ALICE", "BOB", "CAROL"]
}

List to Map

locals {
  name_map = { for name in var.names : name => length(name) }
  # { alice = 5, bob = 3, carol = 5 }
}

Filtering

locals {
  short_names = [for name in var.names : name if length(name) < 5]
  # ["bob"]
}

Object List to Map (for for_each conversion)

variable "users" {
  default = [
    { name = "alice", role = "admin" },
    { name = "bob",   role = "dev" },
  ]
}

locals {
  users_map = { for u in var.users : u.name => u }
}

resource "aws_iam_user" "dev" {
  for_each = local.users_map

  name = each.value.name
  tags = {
    Role = each.value.role
  }
}

This pattern is useful when the input is in list form but you need to pass it to for_each. Lists have ordering, but for_each requires unique keys, so you convert to a map.

Conditional Resource Creation Patterns

Patterns for enabling backups only in production or toggling optional features.

count for 0 or 1

variable "enable_monitoring" {
  type    = bool
  default = false
}

resource "aws_cloudwatch_metric_alarm" "cpu" {
  count = var.enable_monitoring ? 1 : 0

  alarm_name          = "cpu-high"
  metric_name         = "CPUUtilization"
  # ...
}

for_each with a conditional map

locals {
  monitoring_targets = var.enable_monitoring ? {
    cpu    = "CPUUtilization"
    memory = "MemoryUtilization"
  } : {}
}

resource "aws_cloudwatch_metric_alarm" "metrics" {
  for_each = local.monitoring_targets

  alarm_name          = "${each.key}-high"
  metric_name         = each.value
  # ...
}

This is cleaner when conditionally creating multiple resources.

A Few Practical Patterns

Pattern 1: Apply different settings per environment

locals {
  env_config = {
    dev = {
      instance_count = 1
      instance_type  = "t3.micro"
      multi_az       = false
    }
    staging = {
      instance_count = 2
      instance_type  = "t3.medium"
      multi_az       = false
    }
    prod = {
      instance_count = 3
      instance_type  = "m5.large"
      multi_az       = true
    }
  }

  current = local.env_config[var.environment]
}

resource "aws_instance" "app" {
  count = local.current.instance_count

  instance_type = local.current.instance_type
}

Centralize per-environment differences in one place and reference them.

Pattern 2: Create combinations with nested for

variable "regions" {
  default = ["us-east-1", "us-west-2"]
}

variable "environments" {
  default = ["dev", "prod"]
}

locals {
  region_env_pairs = {
    for pair in setproduct(var.regions, var.environments) :
    "${pair[0]}-${pair[1]}" => {
      region      = pair[0]
      environment = pair[1]
    }
  }
}

# 4 combinations: us-east-1-dev, us-east-1-prod, us-west-2-dev, us-west-2-prod

setproduct creates all combinations of two lists, and we assign unique keys to form a map. Useful for creating buckets per region per environment.


Loops and conditionals dramatically expand Terraform code’s expressiveness. Just remember the key principles: count when only the quantity of identical items differs, for_each when items have identity, dynamic for nested block iteration. And for expressions are the Swiss Army knife of data structure transformation.

In the next part, we’ll cover workspaces and environment separation. We’ll look at how to split dev/staging/prod and why Terragrunt might be needed.

Part 11: Workspaces and Environment Separation


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Terraform Part 9 — Modules
Next Post
Terraform Part 11 — Workspaces and Environment Separation