Terraform count vs for_each (and the Errors)
A practical look at when to reach for count, when for_each saves you, and the specific errors each meta-argument tends to throw.
Key takeaways
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.
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.
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.
Why for_each avoids it#
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.
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.
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.
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:
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.
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.
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.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
Container Vulnerability Scanning: Trivy vs Grype vs Snyk
A practitioner's comparison of Trivy, Grype, and Snyk for finding CVEs in container images, plus how to wire scanning into CI without drowning in noise.
Fix the "Cycle" Dependency Error in Terraform
A "Cycle" error means two Terraform resources depend on each other; here is how to find the loop and break it cleanly.
More from DevOps
Explore more articles in this category
Best Log Management Tools in 2026: What You Actually Pay For
Every log platform looks affordable at proof-of-concept volume and expensive at production volume. The pricing model, not the feature list, decides which one you can live with.
Best Managed Kubernetes in 2026: EKS vs GKE vs AKS vs DOKS
The control plane fee is the least interesting number. What separates managed Kubernetes providers is upgrade cadence, how much they run for you, and where the node bill lands.
Your CI Runner Is the Target: Hardening Against npm Worms
The keyv compromise reached 444 packages and over two billion monthly installs through preinstall scripts. The controls that actually stop it are boring and mostly free.
You might have missed
Evergreen posts worth revisiting.