Count and For Each
The idea in one sentence
Section titled “The idea in one sentence”count and for_each are meta-arguments that create more than one instance of the same resource block, count addressing each copy by a numeric index and for_each by a stable key, while a for expression is an unrelated comprehension syntax that transforms one collection into another.
count: near-identical copies, addressed by index
Section titled “count: near-identical copies, addressed by index”count takes a whole number and creates that many instances of a resource, numbered 0 through count - 1. Inside the resource block, count.index holds the current instance’s number, which is typically used to pick an element out of a parallel list. Outside the resource, each instance is addressed with [index] — azurerm_linux_virtual_machine.web[0], azurerm_linux_virtual_machine.web[1], and so on.
variable "vm_names" { type = list(string) description = "Names for the web tier virtual machines, in order" default = ["web-a", "web-b", "web-c"]}
resource "azurerm_linux_virtual_machine" "web" { count = length(var.vm_names)
name = "${var.environment}-${var.vm_names[count.index]}" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location size = "Standard_D2s_v3" admin_username = "azureuser"
network_interface_ids = [ azurerm_network_interface.web[count.index].id, ]
admin_ssh_key { username = "azureuser" public_key = file("~/.ssh/id_rsa.pub") }
os_disk { caching = "ReadWrite" storage_account_type = "Standard_LRS" }
source_image_reference { publisher = "Canonical" offer = "0001-com-ubuntu-server-jammy" sku = "22_04-lts" version = "latest" }}With var.vm_names at its default, this creates three virtual machines: azurerm_linux_virtual_machine.web[0] named web-a, web[1] named web-b, and web[2] named web-c. count.index is what makes each copy slightly different — a different name here, and typically a different network interface, disk, or IP address elsewhere in a real configuration.
count’s foot-gun: removing a middle item shifts every index after it
Section titled “count’s foot-gun: removing a middle item shifts every index after it”count addresses instances purely by position in a list, and that is exactly where it becomes dangerous. Remove an item from the middle of var.vm_names and every element after it slides down one position — Terraform has no way to know that “index 1” now refers to a different name than it did before; it only sees that the value at index 1 changed and the value at index 2 disappeared.
# Before: vm_names = ["web-a", "web-b", "web-c"]# azurerm_linux_virtual_machine.web[0] -> web-a# azurerm_linux_virtual_machine.web[1] -> web-b# azurerm_linux_virtual_machine.web[2] -> web-c
# Remove "web-b" from the middle: vm_names = ["web-a", "web-c"]# azurerm_linux_virtual_machine.web[0] -> web-a (unchanged, untouched)# azurerm_linux_virtual_machine.web[1] -> web-c (was web-b; name changed -> destroy and recreate)# azurerm_linux_virtual_machine.web[2] (index no longer exists -> destroy)Conceptually, only one virtual machine should ever be removed here. What Terraform actually plans is a destroy-and-recreate of web[1] (because the name attribute at that index changed, and name forces replacement) and an outright destroy of web[2] (because the list is now shorter and that index no longer exists) — two disruptive operations to remove one item, and the surviving web-c machine gets rebuilt from scratch even though nothing about it was supposed to change. This is the single most common count foot-gun, and it gets worse the further into a list the removed item sits, because everything after it shifts.
Proven with count over local_file (the exact mechanism, no Azure credentials needed to see it) — starting from ["web-a", "web-b", "web-c"] and removing "web-b", leaving ["web-a", "web-c"]:
$ terraform plan # local_file.web[1] must be replaced-/+ resource "local_file" "web" { ~ content = "web-b" -> "web-c" # 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 = "web-c" -> null - filename = "./out-2.txt" -> null }
Plan: 1 to add, 0 to change, 2 to destroy.Index 1 (originally "web-b") is destroyed and recreated with the content "web-c" — exactly the “rebuilt from scratch even though nothing about it changed” cost described above — and index 2 is destroyed outright. One item removed from a three-item list produced 1 create + 2 destroys.
for_each: one instance per key, unaffected by neighbors
Section titled “for_each: one instance per key, unaffected by neighbors”for_each takes a map or a set of strings and creates one resource instance per key, addressed by that key rather than a numeric position — azurerm_linux_virtual_machine.web["primary"] instead of web[0]. each.key and each.value are available inside the resource block, and for a set(string), the key and the value are the same string.
variable "web_server_roles" { type = set(string) description = "Stable role names for the web tier virtual machines" default = ["primary", "secondary", "dr"]}
resource "azurerm_linux_virtual_machine" "web" { for_each = var.web_server_roles
name = "${var.environment}-web-${each.key}" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location size = "Standard_D2s_v3" admin_username = "azureuser"
network_interface_ids = [ azurerm_network_interface.web[each.key].id, ]
admin_ssh_key { username = "azureuser" public_key = file("~/.ssh/id_rsa.pub") }
os_disk { caching = "ReadWrite" storage_account_type = "Standard_LRS" }
source_image_reference { publisher = "Canonical" offer = "0001-com-ubuntu-server-jammy" sku = "22_04-lts" version = "latest" }}Now remove "secondary" from var.web_server_roles. Terraform destroys exactly azurerm_linux_virtual_machine.web["secondary"] and nothing else — web["primary"] and web["dr"] are addressed by their own keys, which never moved, so Terraform has no reason to touch them at all. There is no shifting, because there is nothing positional to shift. This is why for_each is the safer default for any collection of resources that might grow or shrink from the middle, and count is best reserved for cases where you genuinely just need N identical copies of something with no meaningful identity beyond a number.
Proven with the identical scenario, 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.
for_each on a map: each.key and each.value genuinely differ
Section titled “for_each on a map: each.key and each.value genuinely differ”Over a set(string) (above), each.key and each.value are the same string. Over a map, they differ: each.key is the map key, each.value is that key’s value — the shape you need when different instances of the same resource need genuinely different per-instance arguments, not just a different name.
variable "vm_sizes" { type = map(string) description = "VM size per role name" default = { primary = "Standard_D2s_v3" secondary = "Standard_D2s_v3" dr = "Standard_D4s_v3" }}
resource "azurerm_linux_virtual_machine" "web" { for_each = var.vm_sizes
name = "${var.environment}-web-${each.key}" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location size = each.value admin_username = "azureuser"
network_interface_ids = [ azurerm_network_interface.web[each.key].id, ]
admin_ssh_key { username = "azureuser" public_key = file("~/.ssh/id_rsa.pub") }
os_disk { caching = "ReadWrite" storage_account_type = "Standard_LRS" }
source_image_reference { publisher = "Canonical" offer = "0001-com-ubuntu-server-jammy" sku = "22_04-lts" version = "latest" }}Real output from the equivalent local_file version (content = "vm ${each.key} has size ${each.value}") after apply:
$ cat out-primary.txtvm primary has size Standard_D2s_v3$ cat out-dr.txtvm dr has size Standard_D4s_v3each.key names the instance (used for the VM name and the network interface lookup), each.value carries the size that differs specifically for the dr role.
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. Referencing that resource elsewhere breaks the moment count is 0: azurerm_linux_virtual_machine.monitoring[0].id has no index 0 to read. 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 "azurerm_linux_virtual_machine" "monitoring" { count = var.enable_monitoring ? 1 : 0 # ... same shape as above}
output "monitoring_vm_id" { value = one(azurerm_linux_virtual_machine.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; only two-or-more elements 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. 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 "azurerm_linux_virtual_machine" "web" { for_each = toset(random_pet.names[*].id) # ...}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_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 plan # terraform_data.restart_app will be replaced due to changes in replace_triggered_byPlan: 2 to add, 0 to change, 2 to destroy.A real per-key subnet example
Section titled “A real per-key subnet example”Putting for_each to work the way it actually gets used: one subnet per name, keyed so adding or removing a subnet never disturbs the others. Proven with terraform test and mock_provider "azurerm" {}, since a real plan needs Azure credentials this course never has:
variable "subnets" { type = map(string) default = { web = "10.0.1.0/24" app = "10.0.2.0/24" db = "10.0.3.0/24" }}
resource "azurerm_virtual_network" "main" { name = "vnet-demo" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location address_space = ["10.0.0.0/16"]}
resource "azurerm_subnet" "this" { for_each = var.subnets
name = each.key resource_group_name = azurerm_resource_group.main.name virtual_network_name = azurerm_virtual_network.main.name address_prefixes = [each.value]}Real terraform test run: create all three, then remove "app" from the map and plan again:
mock_provider "azurerm" {}
run "create_three" { command = apply}
run "remove_app_key" { command = plan
variables { subnets = { web = "10.0.1.0/24" db = "10.0.3.0/24" } }}$ terraform test # azurerm_subnet.this["app"] will be destroyedPlan: 0 to add, 0 to change, 1 to destroy.Success! 2 passed, 0 failed.Exactly azurerm_subnet.this["app"] is destroyed — web and db never appear in the plan at all, because their keys never moved.
for expressions: transforming a collection, not creating resources
Section titled “for expressions: transforming a collection, not creating resources”A for expression is a separate piece of syntax that happens to share a name with for_each, and mixing the two up is an easy mistake. for_each (and count) are resource meta-arguments that decide how many instances of a resource to create. A for expression is a general-purpose comprehension — it takes an existing list or map and produces a new list or map, and it can appear anywhere an expression is valid: in a local, an output, a variable default, or an argument to a function.
locals { # [for x in list : expr] transforms a list into a new list vm_names_upper = [for name in var.vm_names : upper(name)]
# {for k, v in map : k => expr} transforms a map into a new map role_to_vm_id = { for role, vm in azurerm_linux_virtual_machine.web : role => vm.id }
# a for expression can filter with an if clause too prod_only_roles = [for role in var.web_server_roles : role if role != "dr"]}local.vm_names_upper has nothing to do with how many virtual machines get created — it just uppercases every string in var.vm_names and hands back a new list, useful anywhere you need the transformed values rather than the originals. local.role_to_vm_id does the same job for a map, pairing each for_each key from azurerm_linux_virtual_machine.web with that instance’s id, which is a common way to hand a clean role -> id mapping to an output or another resource. None of this creates or destroys a resource instance; it only reshapes data that already exists. The resource-level for_each you saw above and a for expression like these happen to share four letters, but they solve entirely different problems.
flowchart TB
subgraph countMeta["count: indexed by position"]
cl["list: web-a, web-b, web-c"] --> c0["index 0 = web-a"]
cl --> c1["index 1 = web-b"]
cl --> c2["index 2 = web-c"]
cr["remove web-b from the middle"] --> cs1["index 1 now web-c: destroy and recreate"]
cr --> cs2["index 2 no longer exists: destroy"]
end
subgraph forEachMeta["for_each: indexed by stable key"]
fs["set: primary, secondary, dr"] --> f0["key primary"]
fs --> f1["key secondary"]
fs --> f2["key dr"]
fr["remove secondary"] --> fs1["key secondary destroyed only"]
fr --> fs2["key primary and key dr 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, a role, a map key you chose) — resilient, because removing one thing never renames another. Every proof above is a consequence of that one distinction.
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 with subnets | 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 with per-role VM sizes | 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 |
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. - “
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
azurerm_linux_virtual_machine.monitoring[0]safely even whencountmight be0.” Proven above: this errors the momentcountevaluates to0.one(azurerm_linux_virtual_machine.monitoring[*].id)is the safe form. - “
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. - “
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) —one(some_resource.name[*].attr)is the fix, proven above. for_eachkeyed on a list index converted to a string when the underlying collection has genuinely meaningful keys available (names, roles) — this reintroducescount’s fragility with extra syntax.for_eachover a value derived from another resource’s computed attribute (an ID) without checking whether that attribute is known before apply — proven above, this is a real, immediateplan-time error.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) instead of an external signal the resource has no other way to react to.
🐛 Spot the bug
variable "vm_names" {type = list(string)default = ["web-a", "web-b", "web-c"]}
resource "azurerm_linux_virtual_machine" "web" {count = length(var.vm_names)name = "${var.environment}-${var.vm_names[count.index]}"# ...}
# Later: ops wants to remove "web-b" from the deploy# variable "vm_names" { default = ["web-a", "web-c"] }Show the answer
Removing "web-b" from the middle of this list does NOT just destroy the web-b VM. Proven above with the identical shape (local_file instead of azurerm_linux_virtual_machine, same mechanism): index 1 ("web-b") is destroyed and recreated as "web-c", and index 2 (the original "web-c") 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.vm_names) (or a map, if per-VM config beyond the name is needed): removing "web-b" then plans exactly 1 to destroy, nothing else touched.