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:
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?
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:
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:
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/16aws_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:
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:
variable "environments" {
type = set(string)
default = ["dev", "prod"]
}Run terraform plan. Clean output:
aws_vpc.main["dev"]— No changesaws_vpc.main["staging"]— Destroyaws_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:
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:
# 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:
# 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
countuses positional indexes; removing or reordering items shifts all indexes downstream and triggers resource recreation.for_eachuses semantic keys that remain stable regardless of collection changes, making it safe for production lists and maps.- Use
for_eachfor any resource collection; usecountonly for on/off toggles with a boolean condition. - Keys in
for_eachare the resource identity. They don't change when you add, remove, or reorder collection items. - Converting a list to a set for
for_eachis a one-line fix:toset(var.my_list). - The index shift problem is insidious in production—data loss and downtime happen silently. Prefer
for_eachas your default and avoid the risk.






Comments
Loading comments…