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,forexpressions, anddynamicblocks. 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 usefor_eachinstead ofcount.
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)5.2. for_each — Repeat over a map or set (recommended)
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
| Criteria | count | for_each |
|---|---|---|
| Based on | integer | map or set of strings |
| Identified by | index [0], [1] | key ["app"] |
| Adding/removing mid-list | ❌ index shift causes recreation | ✅ stable |
| Toggle a resource on/off | ✅ ? 1 : 0 | ✅ ? {...} : {} |
| Each copy needs different config | awkward | ✅ easy with map of objects |
| Recommendation | identical copies / on-off | default to this |
Rule of thumb: Default to
for_each. Only reach forcountfor on/off toggles or N genuinely identical copies.
⚠️
for_eachrequires 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 ifCreating 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 → mapPractical 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_eachneeds 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. Usedynamicwhen 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 : 25.6. Classic traps
| Trap | What happens | How to avoid it |
|---|---|---|
Using count with a dynamic list | Resources recreated when you remove a middle element | Use for_each |
for_each with "known after apply" keys | Invalid for_each argument error | Use static keys, or split the apply |
for_each on a plain list (not set/map) | Error | Wrap with toset(...) or convert to map |
Mixing count and for_each on the same resource | Not allowed | Pick one |
Overusing dynamic | Hard to read | Write blocks explicitly if there are only a few |
Forgetting [0] when count=1 | Wrong reference | Remember count resources are always lists |
5.7. Hands-on Labs
Go to hands-on/ and follow LAB.md:
- Lab 1 —
countand observing the index-shift problem firsthand. - Lab 2 —
for_eachand proving it stays stable when you remove a middle element. - Lab 3 —
forexpressions: list→map, filtering, transforming. - Lab 4 —
dynamicblocks generating multiple configurations. - Lab 5 — Conditional creation (on/off).
✅ Module 05 completion criteria
- Can explain and demo the
countindex-shift problem. - Know how to convert a list of objects into a map for
for_each. - Can write
forexpressions that produce both lists and maps, with filtering. - Can write a
dynamicblock and know when NOT to use one. - Can write conditional creation using both
countandfor_each.
➡️ Next: Module 06 — Modules