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 you moved them.
  • 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

  1. Using count.index in names ("node-${count.index}") so the name changes when the list is reordered.
  2. A list that is not sorted and not stable (toset on a computed list that reshuffles).
  3. for_each over a list of objects without a key — Terraform requires a set or map. Add an explicit key.
  4. Sensitive values in for_each keys — not allowed. Keys must be known and non-sensitive at plan time.
  5. 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 → moved blocks, then plan until empty

See Terraform production best practices for state layout and plan-file hygiene.

Platform evaluation

See OrchesTerra on your infrastructure

Request access to generate architecture from a repository, review the plan, and run governed reconciliation across AWS, Azure, GCP, and OCI.