Skip to content

Count and For Each

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 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 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.txt
instance primary has type t3.micro
$ cat out-secondary.txt
instance secondary has type t3.small

each.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.

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 on
Apply 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_by
Plan: 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.

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
Removing a middle item shifts every count index, but only removes one for_each key

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).

ChoiceBenefitCost
countSimplest syntax for a truly fixed, order-irrelevant number of near-identical resourcesRemoving 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 aboveRequires 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 aboveEvery 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_byForces a deliberate replacement on an external signal, without adding a fake argument to smuggle a dependency in — proven above with a real planThe replacement is total (destroy + recreate), not a partial update — appropriate for “restart,” wrong for anything that should update in place
  • “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_each and a for expression are basically the same feature since they share the word ‘for’.” They are unrelated: for_each is a resource meta-argument that repeats a resource block; a for expression transforms one collection’s data into another and never creates or destroys anything.
  • “You can reference aws_instance.monitoring[0] safely even when count might be 0.” Proven above: this errors the moment count evaluates to 0, since there is no index 0. one(aws_instance.monitoring[*].id) is the safe form — null on empty, the element on exactly one, and only 2+ elements is an actual error.
  • “for_each can key on any computed value, the same way an ordinary argument can reference one.” Proven above: a for_each key 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_resource is 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 adds replace_triggered_by, which null_resource never had.
  • count = length(var.some_list) indexing into var.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, when count on some_resource.name is actually conditional (var.enable ? 1 : 0) — this breaks the moment the flag is false; one(some_resource.name[*].attr) is the fix, proven above.
  • for_each keyed 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 reintroduces count’s fragility with extra syntax, not for_each’s actual benefit.
  • for_each over 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, immediate plan-time error, not something that fails quietly.
  • null_resource still appearing in freshly-generated code for a trigger-only resource — terraform_data is the current, provider-free replacement and is the only one of the two that supports replace_triggered_by.
  • A replace_triggered_by value 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_id
instance_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.

What happens when you remove an item from the middle of a list used with count on a resource?
Why does for_each avoid the cascading destroy-and-recreate problem that count has?
What is the difference between a for expression and the for_each meta-argument, despite the similar name?
Which meta-argument would you choose for a fixed set of three near-identical logging sidecar resources where order and stable identity do not matter at all?
What happens when you run terraform plan against this?
resource "random_pet" "names" {
  count = 2
}

resource "local_file" "web" {
  for_each = toset(random_pet.names[*].id)
  filename = "out-${each.key}.txt"
}
A resource has count = var.enable_x ? 1 : 0, and another resource references its id with one(that_resource[*].id). What does one() return when count evaluates to 0?
What does terraform_data add over the older null_resource, besides not needing a separate provider?