Skip to main content

Terraform for_each vs count: When to Use Each

Key Takeaway

Terraform for_each vs count explained with practical examples. Learn when to use each, how to migrate from count to for_each, and avoid the index reordering problem.

Table of Contents

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.

🚀

Level Up Your Terraform Skills

Hands-on courses, books, and resources from Luca Berton

Luca Berton
Written by

Luca Berton

DevOps Engineer, AWS Partner, Terraform expert, and author. Creator of Ansible Pilot, Terraform Pilot, and CopyPasteLearn.