Skip to content
>_higan
This post has a translation:Read in Tiếng Việt

Terraform #05 — Loops & Conditionals: count, for_each, dynamic

Master count vs for_each (and the index-shift trap), for expressions, dynamic blocks, and conditional resource creation.

Goal: Get fluent with count, for_each, for expressions, and dynamic blocks. This is where you separate people who've been bitten by Terraform from people who haven't — especially when it comes to knowing when to use for_each instead of count.

Common interview question: "What's the difference between count and for_each? When would you use each one?"


5.1. count — Repeat by number

resource "aws_instance" "web" {
  count         = 3
  ami           = "ami-123"
  instance_type = "t3.micro"
 
  tags = {
    Name = "web-${count.index}"   # count.index: 0, 1, 2
  }
}

Creates aws_instance.web[0], aws_instance.web[1], aws_instance.web[2]. Reference a specific one: aws_instance.web[0].id, or all of them: aws_instance.web[*].id (splat expression).

⚠️ The critical problem with count: index shift

Say you have 3 buckets from the list ["a", "b", "c"] using count. You delete "b" — the list becomes ["a", "c"]:

Before:  web[0]="a"  web[1]="b"  web[2]="c"
After:   web[0]="a"  web[1]="c"             ← "c" shifts from index 2 to 1!

Result: Terraform DESTROYS web[1](b) and web[2](c), then CREATES web[1](c) again.
=> "c" gets recreated for no reason, even though you only wanted to remove "b".

👉 This is why you shouldn't use count with a list where elements might be added or removed in the middle.

When is count fine?

  • Creating N identical copies (e.g. 3 instances with the same config).
  • Toggling a resource on or off (conditional creation):
resource "aws_eip" "nat" {
  count = var.enable_nat ? 1 : 0
}
# Reference carefully: aws_eip.nat[0].id (only exists when count >= 1)

for_each identifies resources by key instead of index. Remove an item and only that item is affected — nothing shifts.

resource "aws_instance" "web" {
  for_each      = toset(["app", "api", "worker"])
  ami           = "ami-123"
  instance_type = "t3.micro"
 
  tags = {
    Name = "web-${each.key}"
  }
}

Creates aws_instance.web["app"], aws_instance.web["api"], aws_instance.web["worker"].

Delete "api" → only web["api"] is destroyed. "app" and "worker" don't get touched.

for_each with a map

variable "instances" {
  type = map(object({
    instance_type = string
    az            = string
  }))
  default = {
    app    = { instance_type = "t3.small",  az = "us-east-1a" }
    worker = { instance_type = "t3.medium", az = "us-east-1b" }
  }
}
 
resource "aws_instance" "this" {
  for_each          = var.instances
  ami               = "ami-123"
  instance_type     = each.value.instance_type
  availability_zone = each.value.az
 
  tags = { Name = each.key }
}

count vs for_each — when to use what

Criteriacountfor_each
Based onintegermap or set of strings
Identified byindex [0], [1]key ["app"]
Adding/removing mid-list❌ index shift causes recreation✅ stable
Toggle a resource on/off? 1 : 0? {...} : {}
Each copy needs different configawkward✅ easy with map of objects
Recommendationidentical copies / on-offdefault to this

Rule of thumb: Default to for_each. Only reach for count for on/off toggles or N genuinely identical copies.

⚠️ for_each requires keys that are known at plan time (not "known after apply"). If your keys come from a resource that doesn't exist yet → error. Fix: use static values as keys, or split the apply.


5.3. for expressions — transforming collections

Unlike count/for_each which create resources, for expressions create values (lists or maps).

Creating a list

[for s in var.names : upper(s)]                  # ["A","B"]
[for i, s in var.names : "${i}:${s}"]            # ["0:a","1:b"] (with index)
[for s in var.names : s if length(s) > 3]        # filter with if

Creating a map

{for s in var.names : s => upper(s)}             # {a="A", b="B"}
{for k, v in var.m : k => v if v > 0}            # filter a map
{for inst in var.list : inst.name => inst.ip}    # list → map

Practical example: list of objects → map for for_each

variable "users" {
  type = list(object({ name = string, role = string }))
  default = [
    { name = "alice", role = "admin" },
    { name = "bob",   role = "dev" },
  ]
}
 
locals {
  # Convert list → map so for_each has a stable key
  users_map = { for u in var.users : u.name => u }
}
 
resource "aws_iam_user" "this" {
  for_each = local.users_map
  name     = each.value.name
  tags     = { Role = each.value.role }
}

The "list → map" pattern is everywhere: inputs are often lists because they're easy to write, but for_each needs a map or set.


5.4. dynamic blocks — generating repeated nested blocks

Some resources have nested blocks that repeat (e.g. multiple ingress rules in a security group). dynamic generates them from a collection.

variable "ingress_rules" {
  type = list(object({
    port        = number
    cidr_blocks = list(string)
  }))
  default = [
    { port = 80,  cidr_blocks = ["0.0.0.0/0"] },
    { port = 443, cidr_blocks = ["0.0.0.0/0"] },
    { port = 22,  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.port
      to_port     = ingress.value.port
      protocol    = "tcp"
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}

⚠️ Don't overuse dynamic. It makes code hard to read. If you only have 2-3 fixed blocks, writing them out explicitly is cleaner. Use dynamic when the number of blocks is genuinely variable.


5.5. Conditional creation patterns

Toggle with count

resource "aws_cloudwatch_log_group" "this" {
  count = var.enable_logging ? 1 : 0
  name  = "/app/logs"
}
 
# Safe output when count might be 0:
output "log_group_arn" {
  value = var.enable_logging ? aws_cloudwatch_log_group.this[0].arn : null
}

Toggle with for_each (preserves the key)

resource "aws_instance" "optional" {
  for_each = var.create ? toset(["main"]) : toset([])
  # ...
}

Conditional value inside an attribute

instance_type = var.high_perf ? "c5.xlarge" : "t3.micro"
desired_count = var.environment == "prod" ? 6 : 2

5.6. Classic traps

TrapWhat happensHow to avoid it
Using count with a dynamic listResources recreated when you remove a middle elementUse for_each
for_each with "known after apply" keysInvalid for_each argument errorUse static keys, or split the apply
for_each on a plain list (not set/map)ErrorWrap with toset(...) or convert to map
Mixing count and for_each on the same resourceNot allowedPick one
Overusing dynamicHard to readWrite blocks explicitly if there are only a few
Forgetting [0] when count=1Wrong referenceRemember count resources are always lists

5.7. Hands-on Labs

Go to hands-on/ and follow LAB.md:

  1. Lab 1count and observing the index-shift problem firsthand.
  2. Lab 2for_each and proving it stays stable when you remove a middle element.
  3. Lab 3for expressions: list→map, filtering, transforming.
  4. Lab 4dynamic blocks generating multiple configurations.
  5. Lab 5 — Conditional creation (on/off).

✅ Module 05 completion criteria

  • Can explain and demo the count index-shift problem.
  • Know how to convert a list of objects into a map for for_each.
  • Can write for expressions that produce both lists and maps, with filtering.
  • Can write a dynamic block and know when NOT to use one.
  • Can write conditional creation using both count and for_each.

➡️ Next: Module 06 — Modules

Share: