A practical look at when to reach for count, when for_each saves you, and the specific errors each meta-argument tends to throw.
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.
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.
Say you manage a set of IAM users with count.
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:
variable "users" {
default = ["alice", "carol", "dave"]
}
Watch what the plan does:
# 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.
Rewrite the same thing keyed by name:
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:
# 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.
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.
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:
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.
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.
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:
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:
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.
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.
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.
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.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A service mesh solves real problems and creates new ones. This is the map: what it actually does, when it earns its cost, and how the options compare.
A "Cycle" error means two Terraform resources depend on each other; here is how to find the loop and break it cleanly.
Explore more articles in this category
Terraform's errors are scarier than the fixes. This is the map to the ones everyone hits: what each message means, the safe way out, and how to avoid losing state.
A practical guide to renaming resources, migrating backends, and splitting or merging Terraform state without destroying and recreating infrastructure.
A practical guide to resolving Terraform provider version conflicts and lock file checksum errors across modules, developers, and CI pipelines.
Evergreen posts worth revisiting.