Terraform
Terraform for_each vs count: Choose the Index That Will Not Recreate Prod
Why count indexes by position, why for_each indexes by key, and how to migrate a live resource without a destroy/create.
2026-08-28 · 3 min read
count and for_each both create N instances of a resource. They do not create the same addresses. Terraform’s address is what it uses to decide destroy vs update. Pick the wrong one and a one-line list edit becomes a production recreate.
The addresses
variable "azs" {
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
resource "aws_subnet" "private_count" {
count = length(var.azs)
availability_zone = var.azs[count.index]
cidr_block = cidrsubnet("10.0.0.0/16", 4, count.index)
}
resource "aws_subnet" "private_each" {
for_each = toset(var.azs)
availability_zone = each.key
cidr_block = cidrsubnet("10.0.0.0/16", 4, index(["us-east-1a", "us-east-1b", "us-east-1c"], each.key))
}
Addresses:
aws_subnet.private_count[0]
aws_subnet.private_count[1]
aws_subnet.private_count[2]
aws_subnet.private_each["us-east-1a"]
aws_subnet.private_each["us-east-1b"]
aws_subnet.private_each["us-east-1c"]
Delete us-east-1a from the list:
count: index 0 vanishes, old[1]becomes[0], old[2]becomes[1]. Terraform plans destroy + create for the survivors unless youmovedthem.for_each: only["us-east-1a"]is destroyed. The others keep their keys.
That is the whole argument.
When count is still correct
Use count for a boolean “create this resource or do not”:
resource "aws_cloudwatch_log_group" "extra" {
count = var.enable_debug_logs ? 1 : 0
name = "/app/${var.name}/debug"
}
Refer to it as aws_cloudwatch_log_group.extra[0] behind a guard, or better:
output "log_group" {
value = try(aws_cloudwatch_log_group.extra[0].name, null)
}
Do not use count = length(var.items) for named cloud objects (IAM users, DNS records, subnets, repos).
Maps beat sets when you need extra fields
toset(list) is fine for a list of names. If each item has CIDR, AZ, and tags, use a map:
variable "subnets" {
type = map(object({
az = string
cidr = string
}))
}
resource "aws_subnet" "private" {
for_each = var.subnets
availability_zone = each.value.az
cidr_block = each.value.cidr
tags = {
Name = each.key
}
}
Keys (public-a, private-b) are what you will see in every plan. Choose keys you will not rename.
Migrating count → for_each without downtime
You cannot “change the meta-argument” in place. You move state.
# after changing the resource to for_each
moved {
from = aws_subnet.private[0]
to = aws_subnet.private["us-east-1a"]
}
moved {
from = aws_subnet.private[1]
to = aws_subnet.private["us-east-1b"]
}
Then:
terraform plan
# expect: no destroys for the moved addresses
terraform apply
If you skip moved (or terraform state mv), the plan will destroy the old index and create the new key — same subnet CIDR, new AWS resource id, broken ENIs.
For modules:
moved {
from = module.workers[0]
to = module.workers["jobs"]
}
each inside nested modules
A module called with for_each gets each.key from the caller, not from inside the module. Inside the module, take a name variable.
module "app" {
for_each = var.apps
source = "./modules/app"
name = each.key
image = each.value.image
}
Do not read each in a child module — it is not in scope there.
Common pitfalls
- Using
count.indexin names ("node-${count.index}") so the name changes when the list is reordered. - A list that is not sorted and not stable (
toseton a computed list that reshuffles). for_eachover a list of objects without a key — Terraform requires a set or map. Add an explicit key.- Sensitive values in
for_eachkeys — not allowed. Keys must be known and non-sensitive at plan time. - Unknown-at-plan-time keys (for_each over a resource that is being created in the same apply). Split applies or use a static map.
Rule of thumb
- Boolean create/destroy →
count - Named collection →
for_each+ stable keys - Changing the addressing scheme →
movedblocks, then plan until empty
See Terraform production best practices for state layout and plan-file hygiene.
