Count and For Each
The idea in one sentence
Section titled “The idea in one sentence”count and for_each both create multiple copies of a resource from a single block of configuration, but they address those copies differently — by numeric index versus by stable key — and that difference decides how safely you can change the underlying collection later.
count: a numeric index per copy
Section titled “count: a numeric index per copy”count takes a whole number and creates that many near-identical copies of a resource, indexed 0 through count - 1. Inside the resource, count.index refers to the current copy’s position; outside the resource, each copy is addressed with that same numeric index, like aws_instance.web[0].
variable "instance_names" { type = list(string) default = ["primary", "secondary", "worker"]}
resource "aws_instance" "web" { count = length(var.instance_names)
ami = var.ami_id instance_type = "t3.micro"
tags = { Name = var.instance_names[count.index] }}This creates aws_instance.web[0] tagged "primary", aws_instance.web[1] tagged "secondary", and aws_instance.web[2] tagged "worker". That works fine as long as the list never changes shape in the middle.
count’s real weakness: removing a middle item
Section titled “count’s real weakness: removing a middle item”The problem is specifically about what happens when an item is removed from the middle of var.instance_names. Suppose "secondary" is deleted, leaving ["primary", "worker"]. Conceptually, exactly one thing should happen: destroy the instance that was tagged "secondary". But Terraform does not track “the instance tagged secondary” — it tracks index 0, index 1, index 2. After the list shrinks, index 1 now refers to "worker" instead of "secondary", and index 2 no longer exists at all. Terraform’s plan reflects the index, not the intent:
aws_instance.web[1]is destroyed (it was"secondary") and recreated with the new value at that index ("worker")aws_instance.web[2]("worker", the original) is destroyed, with nothing to replace it
One conceptual removal turns into a cascade of destroy-and-recreate operations for every index after the one that was removed, purely because the addressing scheme is positional. This is the single most common count foot-gun, and it gets worse the larger the list and the earlier the removed item sits.
Proven with count over local_file (no cloud needed to see the exact mechanism) — starting from ["primary", "secondary", "worker"] and removing "secondary", leaving ["primary", "worker"]:
$ terraform plan # local_file.web[1] must be replaced-/+ resource "local_file" "web" { ~ content = "secondary" -> "worker" # forces replacement # (other computed attributes omitted) }
# local_file.web[2] will be destroyed # (because index [2] is out of range for count) - resource "local_file" "web" { - content = "worker" -> null - filename = "./out-2.txt" -> null }
Plan: 1 to add, 0 to change, 2 to destroy.Read that literally: index 1 (originally "secondary") is destroyed and recreated with the content "worker" — the resource that conceptually meant “the worker instance” got destroyed and rebuilt for no reason other than sliding into a different array slot. Index 2 (the original "worker", now with nothing at that position) is destroyed outright with no replacement. One item removed from a three-item list produced 1 create + 2 destroys, not the 1 destroy you’d want.
for_each: a stable key per instance
Section titled “for_each: a stable key per instance”for_each takes a map or a set of strings and creates one resource instance per key, addressed by that key rather than by position — aws_instance.web["primary"] instead of aws_instance.web[0].
variable "instance_names" { type = set(string) default = ["primary", "secondary", "worker"]}
resource "aws_instance" "web" { for_each = var.instance_names
ami = var.ami_id instance_type = "t3.micro"
tags = { Name = each.key }}Now remove "secondary" from the set. Only aws_instance.web["secondary"] is planned for destruction. aws_instance.web["primary"] and aws_instance.web["worker"] are untouched — their keys never moved, so Terraform has no reason to touch them. This is why for_each is the better default for almost anything beyond the simplest fixed-count case where order and identity genuinely do not matter: it makes “remove one thing” mean exactly “destroy one thing,” with everything else left alone.
Proven with the identical scenario as above, for_each over a set(string) instead of count over a list(string):
$ terraform plan # local_file.web["secondary"] will be destroyed
Plan: 0 to add, 0 to change, 1 to destroy.One item removed, one destroy planned — no cascade, because local_file.web["primary"] and local_file.web["worker"] are addressed by their own keys, which never moved.
for_each on a map: each.key and each.value are different things
Section titled “for_each on a map: each.key and each.value are different things”for_each over a set(string) (above) makes each.key and each.value identical — the key and the value are the same string. Over a map(string, ...), they genuinely differ: each.key is the map key, each.value is that key’s value.
variable "instances" { type = map(string) default = { primary = "t3.micro" secondary = "t3.small" }}
resource "aws_instance" "web" { for_each = var.instances
ami = var.ami_id instance_type = each.value
tags = { Name = each.key }}Real output from the equivalent local_file version (content = "instance ${each.key} has type ${each.value}") after apply:
$ cat out-primary.txtinstance primary has type t3.micro$ cat out-secondary.txtinstance secondary has type t3.smalleach.key names the instance, each.value carries its per-instance configuration — this is the shape you reach for whenever different instances of the same resource need genuinely different arguments, not just a different name.
Conditional count and one()
Section titled “Conditional count and one()”count = var.enable_x ? 1 : 0 is the standard pattern for an optional resource — 1 copy if a flag is true, 0 if false. The awkward part is referencing that resource elsewhere: aws_instance.monitoring[0].id breaks the moment count is 0, since there is no index 0. one() solves this: it takes a list of 0 or 1 elements and returns the single element, or null if the list is empty — never erroring either way.
variable "enable_monitoring" { type = bool default = false}
resource "aws_instance" "monitoring" { count = var.enable_monitoring ? 1 : 0 ami = var.ami_id instance_type = "t3.micro"}
output "monitoring_instance_id" { value = one(aws_instance.monitoring[*].id)}Proven with the local_file equivalent, both branches, same configuration:
$ terraform apply -auto-approve # enable_monitoring = false (default)No changes. Your infrastructure matches the configuration.Apply complete! Resources: 0 added, 0 changed, 0 destroyed.(monitoring_file_id output: null -- one() on an empty list)
$ terraform apply -auto-approve -var="enable_monitoring=true" # flip it onApply complete! Resources: 1 added, 0 changed, 0 destroyed.Outputs:monitoring_file_id = "3ea3f9802accf8817bacd6f3df46a73b93ccddec"one([]) returns null with no error; one([x]) returns x; one([x, y, ...]) (two or more elements) is the only case that actually errors, which can’t happen here since count is constrained to 0 or 1.
for_each cannot key on a value only known after apply
Section titled “for_each cannot key on a value only known after apply”for_each’s keys must be fully known at plan time — Terraform needs to know the complete set of instance keys before it can build a plan at all. A collection derived from another resource’s computed attribute (an ID that only exists after that resource is created) fails, even though the same value works fine as an ordinary argument:
resource "random_pet" "names" { count = 2}
resource "aws_instance" "web" { for_each = toset(random_pet.names[*].id)
ami = var.ami_id instance_type = "t3.micro"}Real error (proven with the local_file equivalent — the mechanism is identical regardless of resource type):
$ terraform plan╷│ Error: Invalid for_each argument││ on main.tf line 6, in resource "local_file" "web":│ 6: for_each = toset(random_pet.names[*].id)│ ├────────────────│ │ random_pet.names is tuple with 2 elements││ The "for_each" set includes values derived from resource attributes that│ cannot be determined until apply, and so Terraform cannot determine the│ full set of keys that will identify the instances of this resource.││ When working with unknown values in for_each, it's better to use a map│ value where the keys are defined statically in your configuration and where│ only the values contain apply-time results.╵Terraform’s own suggestion in that error is the fix worth remembering: key on something known statically (a name you chose, an index, an AZ string from a variable) and let only the value side carry an apply-time result.
terraform_data instead of null_resource, and replace_triggered_by
Section titled “terraform_data instead of null_resource, and replace_triggered_by”null_resource (from the separate hashicorp/null provider) used to be the standard way to run a provisioner or hold a triggers map with no real infrastructure attached. terraform_data (built into Terraform core since 1.4, no extra provider needed) replaces it, and adds replace_triggered_by: force a resource to be replaced whenever a referenced value changes, even if none of the resource’s own arguments changed.
resource "local_file" "config" { filename = "${path.module}/config.txt" content = var.config_content}
resource "terraform_data" "restart_app" { input = local_file.config.content
lifecycle { replace_triggered_by = [local_file.config.content] }}Real plan after only local_file.config’s content changes (terraform_data.restart_app has no argument that changed on its own):
$ terraform plan # terraform_data.restart_app will be replaced due to changes in replace_triggered_byPlan: 2 to add, 0 to change, 2 to destroy.replace_triggered_by is exactly the mechanism a “restart this thing when its config changes” pattern needs — without it, terraform_data.restart_app would have no reason to be touched at all, since none of its own arguments reference anything that changed in a way that would normally force replacement.
A real per-AZ subnet example
Section titled “A real per-AZ subnet example”Putting for_each to work the way it actually gets used: one subnet per availability zone, keyed by AZ name so adding or removing an AZ never disturbs the others.
variable "az_names" { type = set(string) default = ["us-east-1a", "us-east-1b", "us-east-1c"]}
resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16"}
resource "aws_subnet" "private" { for_each = { for idx, az in tolist(var.az_names) : az => idx }
vpc_id = aws_vpc.main.id availability_zone = each.key cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, each.value)
tags = { Name = "private-${each.key}" }}Real terraform plan against the actual aws provider (mock credentials, no network reached):
$ terraform plan # aws_vpc.main will be created + resource "aws_vpc" "main" { + cidr_block = "10.0.0.0/16" ... }Plan: 4 to add, 0 to change, 0 to destroy.Four resources: one VPC plus three subnets, one per AZ in var.az_names. The for idx, az in tolist(var.az_names) : az => idx expression converts the set into a map (AZ name → index), which is what lets each.value feed cidrsubnet’s second-octet argument while each.key stays the stable, human-readable AZ name used for addressing and the Name tag.
for expressions: transforming a collection, not repeating a resource
Section titled “for expressions: transforming a collection, not repeating a resource”A for expression is a different concept that happens to share a name with for_each — it is a general-purpose comprehension for transforming one collection into another, and it has nothing to do with creating multiple resources on its own.
locals { upper_names = [for name in var.instance_names : upper(name)]
name_to_id = { for k, v in aws_instance.web : k => v.id }}[for name in var.instance_names : upper(name)] takes a list and produces a new list, one transformed element per input element. {for k, v in aws_instance.web : k => v.id} takes the map of resource instances that for_each created and produces a plain map from name to instance ID. Neither of these lines creates or destroys a single resource — they are pure data transformations you could use inside a local, an output, or a variable’s default. The overlap in the word “for” trips up a lot of newcomers: the for_each meta-argument repeats a resource block once per element of a collection, while a for expression transforms a collection’s contents into a new collection. They are unrelated features that happen to be spelled similarly.
flowchart TB
subgraph count["count: index-based"]
c0["index 0 primary"] --> c1["index 1 destroyed then recreated as worker"]
c1 --> c2["index 2 destroyed, no replacement"]
end
subgraph foreach["for_each: key-based"]
f0["key primary untouched"]
f1["key secondary destroyed"]
f2["key worker untouched"]
end Mental Model
Section titled “Mental Model”count and for_each both answer “how many copies,” but they encode identity completely differently: count derives identity from position in a list (fragile — position shifts whenever the list’s shape changes), while for_each derives identity from a stable key that already meant something (a name, an AZ, a map key you chose) — resilient, because removing one thing never renames another. Every proof above is a consequence of that one distinction: the index-shift cascade, the for_each unknown-value error (Terraform must know every key up front, precisely because keys ARE identity), and replace_triggered_by (a mechanism for triggering identity-preserving replacement deliberately, on your own terms, rather than accidentally via a shifted index).
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
count | Simplest syntax for a truly fixed, order-irrelevant number of near-identical resources | Removing a middle item cascades destroy-and-recreate across every later index — proven above with a real plan diff |
for_each (set) | Removing one key destroys only that resource — proven above | Requires converting a list to a set (or otherwise supplying stable keys) if you started with ordered data |
for_each (map) | each.key/each.value split lets each instance carry genuinely different per-instance config — proven above | Every key must be known before plan — proven above, a key derived from another resource’s computed attribute is a real, hard error |
terraform_data + replace_triggered_by | Forces a deliberate replacement on an external signal, without adding a fake argument to smuggle a dependency in — proven above with a real plan | The replacement is total (destroy + recreate), not a partial update — appropriate for “restart,” wrong for anything that should update in place |
Common Misconceptions
Section titled “Common Misconceptions”- “Removing an item from a
count-based list only affects the resource for that item.” Proven above: it cascades. Every index after the removed one shifts, and Terraform reads that shift as destroy-and-recreate for each shifted index, not a single clean removal. - “
for_eachand aforexpression are basically the same feature since they share the word ‘for’.” They are unrelated:for_eachis a resource meta-argument that repeats a resource block; aforexpression transforms one collection’s data into another and never creates or destroys anything. - “You can reference
aws_instance.monitoring[0]safely even whencountmight be0.” Proven above: this errors the momentcountevaluates to0, since there is no index0.one(aws_instance.monitoring[*].id)is the safe form —nullon empty, the element on exactly one, and only 2+ elements is an actual error. - “
for_eachcan key on any computed value, the same way an ordinary argument can reference one.” Proven above: afor_eachkey must be fully known at plan time. A key derived from another resource’s apply-time attribute is a real, named error, with Terraform’s own error text suggesting the fix (static keys, apply-time values only). - “
null_resourceis still the modern way to hold a trigger with no real infrastructure.”terraform_data(built into Terraform core since 1.4) replaces it and needs no separate provider; it also addsreplace_triggered_by, whichnull_resourcenever had.
Reading AI-generated code
Section titled “Reading AI-generated code”count = length(var.some_list)indexing intovar.some_list[count.index]for anything that might have an item removed from the middle later — proven above, this is the exact shape that produces the destroy-and-recreate cascade.- A generated resource referencing
some_resource.name[0]unconditionally, whencountonsome_resource.nameis actually conditional (var.enable ? 1 : 0) — this breaks the moment the flag isfalse;one(some_resource.name[*].attr)is the fix, proven above. for_eachkeyed on a list index converted to a string (for_each = toset([for i in range(3) : tostring(i)])) when the underlying collection has genuinely meaningful keys available (names, AZs) — this reintroducescount’s fragility with extra syntax, notfor_each’s actual benefit.for_eachover a value derived from another resource’s computed attribute (an ID, an ARN) without checking whether that attribute is known before apply — proven above, this is a real, immediateplan-time error, not something that fails quietly.null_resourcestill appearing in freshly-generated code for a trigger-only resource —terraform_datais the current, provider-free replacement and is the only one of the two that supportsreplace_triggered_by.- A
replace_triggered_byvalue pointing at an argument that’s already part of the resource’s own configuration (redundant — an ordinary argument change already triggers whatever update or replacement that argument’s schema requires) instead of an external signal the resource has no other way to react to.
🐛 Spot the bug
variable "instance_names" {type = list(string)default = ["primary", "secondary", "worker"]}
resource "aws_instance" "web" {count = length(var.instance_names)
ami = var.ami_idinstance_type = "t3.micro"
tags = { Name = var.instance_names[count.index]}}
# Later: ops wants to remove "secondary" from the deploy# variable "instance_names" { default = ["primary", "worker"] }Show the answer
Removing "secondary" from the middle of this list does NOT just destroy the “secondary” instance. Proven above with the identical shape (local_file instead of aws_instance, same mechanism): index 1 ("secondary") is destroyed and recreated as "worker", and index 2 (the original "worker") is destroyed outright with nothing to replace it —
Plan: 1 to add, 0 to change, 2 to destroy.— for what should conceptually be a single destroy. The fix is for_each over toset(var.instance_names) (or a map, if per-instance config beyond the name is needed): removing "secondary" then plans exactly 1 to destroy, nothing else touched, because each instance is addressed by its own name instead of its position in the list.