TerraformPilot

Terraform

Terraform for_each vs count - Which Loop to Use

Compare Terraform for_each and count. Learn when to use each with examples covering index stability, conditional creation, and map-based resource management.

LLuca Berton1 min read

Quick Answer

#

Use for_each when resources have meaningful names/keys (stable addressing). Use count for simple "create N copies" or conditional creation (0 or 1). for_each is safer because removing an item doesn't reindex all other resources.

The Reindexing Problem with count

#
# count creates resources by index
resource "aws_subnet" "private" {
  count      = 3
  cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
}
# aws_subnet.private[0], aws_subnet.private[1], aws_subnet.private[2]
 
# If you remove the middle subnet, indexes shift:
# [0] stays, [1] becomes [0]'s old [2] — Terraform destroys and recreates!
# for_each uses keys — no reindexing
resource "aws_subnet" "private" {
  for_each   = toset(["us-east-1a", "us-east-1b", "us-east-1c"])
  cidr_block = cidrsubnet(var.vpc_cidr, 8, index(tolist(toset(["us-east-1a", "us-east-1b", "us-east-1c"])), each.key))
  availability_zone = each.key
}
# aws_subnet.private["us-east-1a"], aws_subnet.private["us-east-1b"], ...
 
# Removing "us-east-1b" only destroys that one subnet — others untouched

When to Use count

#

Conditional Resource Creation

#
variable "create_bastion" {
  type    = bool
  default = false
}
 
resource "aws_instance" "bastion" {
  count         = var.create_bastion ? 1 : 0
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
}
 
# Reference: aws_instance.bastion[0].public_ip (when created)

Simple N Copies

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

When to Use for_each

#

Named Resources from a Map

#
variable "subnets" {
  type = map(object({
    cidr = string
    az   = string
    tier = string
  }))
  default = {
    "private-a" = { cidr = "10.0.1.0/24", az = "us-east-1a", tier = "private" }
    "private-b" = { cidr = "10.0.2.0/24", az = "us-east-1b", tier = "private" }
    "public-a"  = { cidr = "10.0.3.0/24", az = "us-east-1a", tier = "public" }
  }
}
 
resource "aws_subnet" "main" {
  for_each          = var.subnets
  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value.cidr
  availability_zone = each.value.az
  tags              = { Name = each.key, Tier = each.value.tier }
}

Resources from a Set

#
resource "aws_iam_user" "team" {
  for_each = toset(["alice", "bob", "carol"])
  name     = each.value
}

Comparison Table

#
Featurecountfor_each
Address formatresource[0], resource[1]resource["key"]
On item removalReindexes — may destroy/recreateOnly removes that item
Input typeNumberSet or map
Conditional creationcount = 0 or 1⚠️ Possible but verbose
Known at plan timeMust know numberMust know keys
Reference syntaxresource[0].idresource["key"].id

Converting count to for_each

#
# Before (count)
variable "az_list" {
  default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
resource "aws_subnet" "private" {
  count             = length(var.az_list)
  availability_zone = var.az_list[count.index]
}
 
# After (for_each)
resource "aws_subnet" "private" {
  for_each          = toset(var.az_list)
  availability_zone = each.key
}
 
# Migrate state:
# terraform state mv 'aws_subnet.private[0]' 'aws_subnet.private["us-east-1a"]'
# terraform state mv 'aws_subnet.private[1]' 'aws_subnet.private["us-east-1b"]'
#

Conclusion

#

Default to for_each for most use cases — it creates stable, key-based resource addresses that don't shift when items are added or removed. Use count for simple conditional creation (count = var.enabled ? 1 : 0) and fixed-number copies where order doesn't matter.

#Terraform#Best Practices#Infrastructure as Code

Share this article