TerraformPilot

DevOps

Terraform for_each vs count: When to Use Each

Terraform for_each vs count explained with practical examples. Learn when to use each, how to migrate from count to for_each

LLuca Berton2 min read

Both count and for_each create multiple instances of a resource. The difference: count uses numeric indexes, for_each uses string keys. This matters more than you'd think — count can destroy your resources when you remove an item from the middle of a list.

The Problem with count

#
variable "subnet_cidrs" {
  default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
}
 
resource "aws_subnet" "main" {
  count      = length(var.subnet_cidrs)
  vpc_id     = aws_vpc.main.id
  cidr_block = var.subnet_cidrs[count.index]
}

State addresses:

aws_subnet.main[0] → 10.0.1.0/24
aws_subnet.main[1] → 10.0.2.0/24
aws_subnet.main[2] → 10.0.3.0/24

Now remove the middle subnet (10.0.2.0/24):

variable "subnet_cidrs" {
  default = ["10.0.1.0/24", "10.0.3.0/24"]  # Removed 10.0.2.0/24
}
terraform plan:
~ aws_subnet.main[1]   10.0.2.0/24 → 10.0.3.0/24  (update in-place — FAILS, can't change CIDR)
- aws_subnet.main[2]   10.0.3.0/24  (destroy!)

Count shifted all indexes down. Subnet [1] tries to change from 10.0.2.0 to 10.0.3.0 (which fails), and subnet [2] gets destroyed. This is production-breaking.

for_each Solves This

#
variable "subnets" {
  default = {
    "us-east-1a" = "10.0.1.0/24"
    "us-east-1b" = "10.0.2.0/24"
    "us-east-1c" = "10.0.3.0/24"
  }
}
 
resource "aws_subnet" "main" {
  for_each = var.subnets
 
  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value
  availability_zone = each.key
}

State addresses:

aws_subnet.main["us-east-1a"] → 10.0.1.0/24
aws_subnet.main["us-east-1b"] → 10.0.2.0/24
aws_subnet.main["us-east-1c"] → 10.0.3.0/24

Remove the middle subnet:

variable "subnets" {
  default = {
    "us-east-1a" = "10.0.1.0/24"
    "us-east-1c" = "10.0.3.0/24"  # Only "us-east-1b" removed
  }
}
terraform plan:
- aws_subnet.main["us-east-1b"]  (destroy — only this one!)

Only the removed subnet is destroyed. The others are untouched.

Side-by-Side Comparison

#
Featurecountfor_each
Key typeNumeric index [0], [1], [2]String key ["name"], ["az"]
Remove middle item❌ Shifts all indexes (destructive)✅ Only removes that key
Reordering❌ Triggers destroy/create✅ Keys are stable
Input typeNumberMap or set of strings
Conditionalcount = var.enabled ? 1 : 0⚠️ More verbose
Referenceaws_subnet.main[0]aws_subnet.main["us-east-1a"]
Best forOn/off togglesCollections of distinct resources

When to Use count

#

Toggle a Resource On/Off

#
variable "create_bastion" {
  type    = bool
  default = false
}
 
resource "aws_instance" "bastion" {
  count = var.create_bastion ? 1 : 0
 
  ami           = data.aws_ami.al2023.id
  instance_type = "t3.micro"
 
  tags = { Name = "bastion" }
}
 
# Reference:
output "bastion_ip" {
  value = var.create_bastion ? aws_instance.bastion[0].public_ip : null
}

Create N Identical Resources

#
variable "worker_count" {
  default = 3
}
 
resource "aws_instance" "worker" {
  count = var.worker_count
 
  ami           = data.aws_ami.al2023.id
  instance_type = "t3.medium"
 
  tags = { Name = "worker-${count.index}" }
}

This is safe because the instances are interchangeable — it doesn't matter if indexes shift.

When to Use for_each

#

Named Resources That Differ

#
variable "buckets" {
  default = {
    logs = {
      versioning = true
      lifecycle_days = 90
    }
    artifacts = {
      versioning = true
      lifecycle_days = 30
    }
    backups = {
      versioning = false
      lifecycle_days = 365
    }
  }
}
 
resource "aws_s3_bucket" "this" {
  for_each = var.buckets
  bucket   = "${var.project}-${each.key}"
}
 
resource "aws_s3_bucket_versioning" "this" {
  for_each = { for k, v in var.buckets : k => v if v.versioning }
  bucket   = aws_s3_bucket.this[each.key].id
 
  versioning_configuration {
    status = "Enabled"
  }
}

Security Group Rules

#
variable "ingress_rules" {
  default = {
    http  = { port = 80,  cidr = "0.0.0.0/0" }
    https = { port = 443, cidr = "0.0.0.0/0" }
    ssh   = { port = 22,  cidr = "10.0.0.0/8" }
  }
}
 
resource "aws_security_group_rule" "ingress" {
  for_each = var.ingress_rules
 
  type              = "ingress"
  security_group_id = aws_security_group.web.id
  from_port         = each.value.port
  to_port           = each.value.port
  protocol          = "tcp"
  cidr_blocks       = [each.value.cidr]
 
  description = each.key
}

IAM Users

#
variable "developers" {
  type    = set(string)
  default = ["alice", "bob", "carol"]
}
 
resource "aws_iam_user" "dev" {
  for_each = var.developers
  name     = each.key
}
 
# Remove "bob" → only bob's user is deleted
# alice and carol are untouched

Converting count to for_each

#

Use moved blocks to avoid destroying resources:

# Old: count-based
# resource "aws_subnet" "public" {
#   count = 3
#   ...
# }
 
# New: for_each-based
moved {
  from = aws_subnet.public[0]
  to   = aws_subnet.public["us-east-1a"]
}
 
moved {
  from = aws_subnet.public[1]
  to   = aws_subnet.public["us-east-1b"]
}
 
moved {
  from = aws_subnet.public[2]
  to   = aws_subnet.public["us-east-1c"]
}
 
resource "aws_subnet" "public" {
  for_each = {
    "us-east-1a" = "10.0.0.0/24"
    "us-east-1b" = "10.0.1.0/24"
    "us-east-1c" = "10.0.2.0/24"
  }
 
  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value
  availability_zone = each.key
}

Common for_each Patterns

#

Convert List to Map

#
variable "names" {
  default = ["web", "api", "worker"]
}
 
resource "aws_ecs_service" "this" {
  # Convert list to set
  for_each = toset(var.names)
  name     = each.key
}

Filter with for Expression

#
variable "instances" {
  default = {
    web    = { type = "t3.micro", enabled = true }
    api    = { type = "t3.small", enabled = true }
    legacy = { type = "t3.micro", enabled = false }
  }
}
 
resource "aws_instance" "this" {
  # Only create enabled instances
  for_each = { for k, v in var.instances : k => v if v.enabled }
 
  instance_type = each.value.type
  ami           = data.aws_ami.al2023.id
  tags          = { Name = each.key }
}

Rule of Thumb

#
Need to toggle on/off?           → count (0 or 1)
N identical, interchangeable?    → count (but for_each is still safer)
Named, distinct resources?       → for_each (always)
Resources you might remove one?  → for_each (always)

Hands-On Courses

#

Conclusion

#

Use count for simple on/off toggles (count = var.enabled ? 1 : 0). Use for_each for everything else — collections of resources, named items, anything where you might add or remove items. The index reordering problem with count has caused more production incidents than any other Terraform gotcha. When in doubt, use for_each.

#Terraform#DevOps#IaC#HCL

Share this article