Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 018 / 100

count vs for_each: a Terraform & IaC guide for developers

Create many resources the right way.

Expona Admin7 min read

When Terraform resource multiplication goes wrong

You're deploying infrastructure at scale, and you need to create dozens of similar resources—S3 buckets, security groups, database instances. Terraform gives you two built-in mechanisms to do this: count and for_each. Pick the wrong one, and you'll spend an afternoon watching Terraform tear down and rebuild resources that never needed to change. This guide walks through count vs for_each: a Terraform & IaC guide for developers, showing when each shines and why one is almost always the better choice for real production work.

count — Simple numbers of identical resources

count is Terraform's original looping mechanism. You specify a number, and Terraform creates that many copies of a resource. It's straightforward and works well for simple cases.

When it makes sense: You want exactly three load balancers, or you're toggling a resource on and off based on a boolean.

Here's a basic example:

hcl
resource "aws_s3_bucket" "logs" {
  count  = 3
  bucket = "acme-logs-${count.index}"
}

output "bucket_names" {
  value = aws_s3_bucket.logs[*].id
}

This creates three S3 buckets named acme-logs-0, acme-logs-1, and acme-logs-2. Terraform tracks them internally by their index position: 0, 1, 2.

The syntax feels natural. You reference instances with bracket notation: aws_s3_bucket.logs[0], aws_s3_bucket.logs[1]. You can use count.index inside the resource to make each one slightly different.

The on/off toggle: count genuinely shines for conditional resources. Need to create a CloudWatch alarm only in production?

hcl
resource "aws_cloudwatch_metric_alarm" "high_cpu" {
  count             = var.enable_alarms ? 1 : 0
  alarm_name        = "high-cpu-${var.environment}"
  comparison_operator = "GreaterThanThreshold"
  threshold         = 80
  # ...
}

When enable_alarms is false, the count is 0, and no alarm is created. When it's true, count is 1, and the alarm exists. Simple, predictable, exactly what you want.

The count problem — Removing an item shifts indexes and recreates resources

Here's where count falls apart in practice. Imagine you have a list of environment names and you're creating a VPC for each:

hcl
variable "environments" {
  type    = list(string)
  default = ["dev", "staging", "prod"]
}

resource "aws_vpc" "main" {
  count             = length(var.environments)
  cidr_block        = "10.${count.index}.0.0/16"
  tags = {
    Name = var.environments[count.index]
  }
}

Everything works. Three VPCs exist: index 0 (dev), index 1 (staging), index 2 (prod).

Now a colleague removes "staging" from the list:

hcl
variable "environments" {
  type    = list(string)
  default = ["dev", "prod"]  # staging removed
}

Run terraform plan. Disaster. Terraform sees:

  • aws_vpc.main[0] (dev) — Keep it, cidr is still 10.0.0.0/16
  • aws_vpc.main[1] (staging) — Destroy it
  • New aws_vpc.main[1] needed for prod with cidr 10.1.0.0/16 — Create it

But wait: the old aws_vpc.main[1] was staging. The new aws_vpc.main[1] is prod. Terraform destroys the staging VPC and creates a new one for prod. Subnets, route tables, EC2 instances, security groups—all dependent resources get torn down and rebuilt. Downtime. Data loss. Angry on-call pages.

This is the fundamental problem with count on ordered lists: the index is positional, not semantic. Remove an item from the middle, and every item after it shifts its index. Terraform sees those shifted indexes as different resources and recreates them.

In a small test environment, you rebuild a VPC in seconds. In production with real data and dependent services, this is a catastrophe.

for_each — Keyed by name, stable when items change

for_each loops over a map or set, assigning each item a unique string key that is independent of position. Keys are stable. Remove an item, and the remaining keys stay the same.

Rewrite the VPC example with for_each:

hcl
variable "environments" {
  type    = set(string)
  default = ["dev", "staging", "prod"]
}

resource "aws_vpc" "main" {
  for_each   = var.environments
  cidr_block = "10.${index(sort(var.environments), each.value)}.0.0/16"
  tags = {
    Name = each.value
  }
}

output "vpc_ids" {
  value = { for env, vpc in aws_vpc.main : env => vpc.id }
}

Now Terraform creates resources keyed by name: aws_vpc.main["dev"], aws_vpc.main["staging"], aws_vpc.main["prod"].

Remove staging:

hcl
variable "environments" {
  type    = set(string)
  default = ["dev", "prod"]
}

Run terraform plan. Clean output:

  • aws_vpc.main["dev"] — No changes
  • aws_vpc.main["staging"] — Destroy
  • aws_vpc.main["prod"] — No changes

Only staging is destroyed. Dev and prod are untouched. Their keys remain constant. Their resource IDs don't change. Dependent resources stay running.

Why keys matter: The key is the identity. In count, identity is the index. In for_each, identity is the key itself. Keys don't shift when you reorder or remove items.

Here's another practical example with S3 buckets, using a map of bucket names to purposes:

hcl
variable "s3_buckets" {
  type = map(object({
    versioning = bool
  }))
  default = {
    logs = {
      versioning = false
    }
    assets = {
      versioning = true
    }
    backups = {
      versioning = true
    }
  }
}

resource "aws_s3_bucket" "this" {
  for_each = var.s3_buckets
  bucket   = "acme-${each.key}"

  tags = {
    Purpose = each.key
  }
}

resource "aws_s3_bucket_versioning" "this" {
  for_each = var.s3_buckets
  bucket   = aws_s3_bucket.this[each.key].id

  versioning_configuration {
    status = each.value.versioning ? "Enabled" : "Suspended"
  }
}

The key (each.key) is "logs", "assets", or "backups". The value (each.value) is the configuration object. If you later add a "metrics" bucket, only that new bucket is created. Everything else stays stable.

Prefer for_each — Use count only for on/off toggles

The Terraform community consensus is strong: use for_each for collections, use count only for boolean toggles. HashiCorp themselves recommend this approach in production.

When to use for_each:

  • Creating multiple resources from a list, set, or map (databases, buckets, security group rules, subnets)
  • Any scenario where the collection might grow, shrink, or reorder
  • When you need predictable, stable resource identity

When to use count:

  • Toggling a single resource on/off with a boolean (count = var.enabled ? 1 : 0)
  • Creating a fixed, never-changing number of identical resources where order doesn't matter and nothing depends on position

In most real-world modules, for_each is the safer default. It's harder to break accidentally. It scales cleanly. Teams can add and remove items from lists without surprise resource destruction.

Common mistakes

Mixing count and for_each on the same resource: Don't do this. Pick one. Terraform will error immediately.

Using lists with for_each: for_each works with maps and sets, not lists. If you pass a list, convert it first:

hcl
# Wrong
for_each = var.my_list

# Right
for_each = toset(var.my_list)
# or
for_each = { for item in var.my_list : item => item }

Forgetting to update references: If you refactor from count to for_each, update all references in your outputs and data sources. aws_instance.main[0] becomes aws_instance.main["key-name"].

Using count.index in computed keys: If the key itself depends on count.index, you lose the stability benefit entirely:

hcl
# Bad: defeats the purpose of for_each
for_each = {
  for idx, item in var.items : "item-${idx}" => item
}

Use a stable attribute of the item as the key instead.

Key takeaways

  • count uses positional indexes; removing or reordering items shifts all indexes downstream and triggers resource recreation.
  • for_each uses semantic keys that remain stable regardless of collection changes, making it safe for production lists and maps.
  • Use for_each for any resource collection; use count only for on/off toggles with a boolean condition.
  • Keys in for_each are the resource identity. They don't change when you add, remove, or reorder collection items.
  • Converting a list to a set for for_each is a one-line fix: toset(var.my_list).
  • The index shift problem is insidious in production—data loss and downtime happen silently. Prefer for_each as your default and avoid the risk.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles