Terraform count vs for_each: Stable Resource Addresses and Safe Refactors

The Choice Is Really About Identity

Both count and for_each create multiple resource instances. The important difference is how Terraform identifies them in state. count uses numeric indexes; for_each uses keys you choose.

That difference is harmless until a collection changes. Then it determines whether Terraform updates one object or appears to reshuffle an entire fleet.

---

When count Is the Right Tool

Use count when instances are interchangeable and their position is meaningful, or for a simple zero-or-one resource:

variable "enable_bastion" {
  type    = bool
  default = false
}

resource "fakecloud_instance" "bastion" {
  count = var.enable_bastion ? 1 : 0
  name  = "ops-bastion"
  size  = "small"
}

output "bastion_ip" {
  value = try(fakecloud_instance.bastion[0].private_ip, null)
}

The address is fakecloud_instance.bastion[0]. The awkward output is a clue: conditional count changes a single object into a list of zero or one objects.

For a uniform pool, indexes can be acceptable:

resource "fakecloud_instance" "worker" {
  count = 3
  name  = format("worker-%02d", count.index + 1)
  size  = "medium"
}

If worker 1, 2, and 3 are truly disposable peers, numeric identity matches the design.

---

The List-Index Trap

This looks tidy but is fragile:

variable "service_names" {
  type    = list(string)
  default = ["api", "billing", "search"]
}

resource "fakecloud_instance" "service" {
  count = length(var.service_names)
  name  = var.service_names[count.index]
}

Remove billing and the list becomes ["api", "search"]. Address service[1], formerly billing, now describes search. Terraform may update or replace it, then destroy old service[2]. The intent was one deletion; index identity turned it into a shuffle.

---

Use for_each for Named Things

for_each makes the business identity part of the address:

variable "services" {
  type = map(object({
    size = string
    tier = string
  }))
}

resource "fakecloud_instance" "service" {
  for_each = var.services

  name = each.key
  size = each.value.size
  tags = { tier = each.value.tier }
}
services = {
  api     = { size = "large",  tier = "frontend" }
  billing = { size = "medium", tier = "backend" }
  search  = { size = "large",  tier = "backend" }
}

The addresses are service["api"], service["billing"], and service["search"]. Removing billing removes exactly one binding.

Keys must be known before apply and must not be sensitive. Do not key resources by values returned only after creation. Choose durable keys: service slug, region code, or an immutable team identifier. A display name that people frequently rename is a poor key because changing it means changing resource identity.

Sets work for simple strings:

for_each = toset(["api", "billing", "search"])
name     = each.value

Maps are better when each instance has additional settings.

---

Chaining for_each

A resource created with for_each behaves as a map, so another resource can use the same identities:

resource "fakecloud_dns_record" "service" {
  for_each = fakecloud_instance.service

  name  = "${each.key}.internal.example"
  value = each.value.private_ip
}

The shared keys communicate that each DNS record belongs to one service instance and give Terraform an implicit dependency through the reference.

---

Refactor count to for_each Without Replacement

Changing syntax alone changes addresses. Preserve bindings with explicit moves:

moved {
  from = fakecloud_instance.service[0]
  to   = fakecloud_instance.service["api"]
}

moved {
  from = fakecloud_instance.service[1]
  to   = fakecloud_instance.service["billing"]
}

moved {
  from = fakecloud_instance.service[2]
  to   = fakecloud_instance.service["search"]
}

Run terraform plan. A correct plan reports address moves with no destroy/create actions. Commit the moves with the refactor and retain them long enough for every state using the module to cross the migration. Removing them immediately can break an environment that has not applied recently.

For many generated instances, script the generation of moved blocks or use reviewed terraform state mv commands, but never infer the mapping from a reordered list after the fact.

Decision Rule

Use count for a conditional singleton or genuinely fungible numbered pool. Use for_each whenever instances have stable names or independent settings. The count to for_each lab lets you observe address stability, and the moved block lab covers the production-safe migration.