Terraform count vs for_each (and the Errors)

Both count and for_each let you stamp out multiple copies of a resource from one block. They look interchangeable in a demo. They are not, and the difference shows up the first time you edit the middle of a list and watch Terraform propose to destroy half your infrastructure.

The core difference: index vs key #

count gives you a numbered list. You ask for N copies and Terraform addresses them by position: aws_instance.web[0], aws_instance.web[1], and so on. The identity of each resource is its index, nothing more.

for_each gives you a map or a set. Each resource is addressed by a stable key you choose: aws_instance.web["api"], aws_instance.web["worker"]. The identity is the key, and the key does not move when its neighbors change.

That single distinction, position versus key, is the whole story behind most of the pain people hit.

The classic count problem #

Say you manage a set of IAM users with count.

hcl.hcl

variable "users" {
default = ["alice", "bob", "carol", "dave"]
}

resource "aws_iam_user" "team" {
count = length(var.users)
name = var.users[count.index]
}

This applies cleanly. team[0] is alice, team[1] is bob, and so on down the list. Now Bob leaves and you remove him from the middle:

hcl.hcl

variable "users" {
default = ["alice", "carol", "dave"]
}

Watch what the plan does:

text.text

# aws_iam_user.team[1] must be replaced
~ name = "bob" -> "carol"
# aws_iam_user.team[2] must be replaced
~ name = "carol" -> "dave"
# aws_iam_user.team[3] will be destroyed

Plan: 0 to add, 0 to change, 3 to destroy, 2 to replace.

You deleted one user and Terraform wants to touch three. Because every index after the removed element shifts down by one, team[1] is no longer bob but carol, team[2] becomes dave, and the tail of the list gets destroyed. For IAM users that is an annoyance. For stateful resources like databases or EBS volumes, an index shift is a data-loss incident.

Why for_each avoids it #

Rewrite the same thing keyed by name:

hcl.hcl

variable "users" {
default = ["alice", "bob", "carol", "dave"]
}

resource "aws_iam_user" "team" {
for_each = toset(var.users)
name = each.key
}

Now the resources are team["alice"], team["bob"], team["carol"], team["dave"]. Remove bob and the plan is exactly what you meant:

text.text

# aws_iam_user.team["bob"] will be destroyed

Plan: 0 to add, 0 to change, 1 to destroy.

Carol and dave do not move, because their keys never changed. This is the reason the community guidance is to reach for for_each by default whenever the collection is a set of distinct things rather than a raw quantity.

The errors each one causes #

count depends on apply-time values: The most common count failure is asking Terraform to size a list from something it cannot know until apply.

hcl.hcl

resource "aws_instance" "web" {
count = length(data.aws_availability_zones.available.names)
# ...
}

If that data source or an upstream resource attribute is not resolvable during plan, you get:

text.text

Error: Invalid count argument

The "count" value depends on resource attributes that cannot be
determined until apply, so Terraform cannot predict how many instances
will be created. To work around this, use the -target argument to first
apply only the resources that the count depends on.

Terraform has to know the number of instances before it can build the plan graph. If the count is computed downstream, it cannot.

for_each with unknown or duplicate keys: for_each has a stricter cousin of the same problem. The keys must be known at plan time, even if the values are not.

text.text

Error: Invalid for_each argument

The "for_each" set includes values derived from resource attributes that
cannot be determined until apply.

The fix is usually to key off something static (a variable, a local literal) and keep the unknown, apply-time data in the value. You also hit a hard error when a toset() produces duplicate keys, or when two map entries collapse to the same string. Keys are unique by definition, so Terraform refuses the ambiguous input rather than silently dropping one.

Converting count to for_each without recreating everything #

The catch: switching a block from count to for_each changes every resource address. team[0] becomes team["alice"]. To Terraform those are different resources, so a naive switch plans a full destroy-and-recreate. You migrate the state instead.

The clean, reviewable way is a moved block committed alongside the change:

hcl.hcl

moved {
from = aws_iam_user.team[0]
to = aws_iam_user.team["alice"]
}

moved {
from = aws_iam_user.team[1]
to = aws_iam_user.team["bob"]
}

Run terraform plan and the moves show up as address changes with zero replacements. The imperative equivalent, if you prefer to do it out of band, is:

bash.bash

terraform state mv 'aws_iam_user.team[0]' 'aws_iam_user.team["alice"]'
terraform state mv 'aws_iam_user.team[1]' 'aws_iam_user.team["bob"]'

Prefer moved blocks. They live in code, survive review, and every teammate who applies the change gets the migration for free instead of running one-off CLI commands against shared state.

When count is still the right call #

count is not a mistake, it is a different tool. The place it genuinely wins is a simple on/off toggle, where you are not iterating a collection at all.

hcl.hcl

resource "aws_cloudwatch_dashboard" "main" {
count = var.enable_dashboard ? 1 : 0
# ...
}

Here there is no list to reorder and no key that means anything. You want zero or one, and count = var.enabled ? 1 : 0 reads exactly as intended. Reach for it when the quantity is the whole point and the elements have no distinct identity worth preserving. Everything else, the sets and maps of named things, belongs in for_each.

For the wider catalog of plan-time and apply-time failures, see the Terraform error troubleshooting guide.

The call we'd make #

Default to for_each for any collection of distinct resources, and treat stable keys as a first-class design decision rather than an afterthought. Reserve count for boolean toggles where a single index carries no meaning. When you migrate an existing count block, always ship a moved block so the change is a rename in state, not a rebuild in production. Do that and the two errors above stop being surprises and become things you designed around.