Skip to content

Count and For Each

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.

Terminal window
# 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.txt
vm primary has size Standard_D2s_v3
$ cat out-dr.txt
vm dr has size Standard_D4s_v3

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

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 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; 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_by
Plan: 2 to add, 0 to change, 2 to destroy.

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:

tests/removal.tftest.hcl
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 destroyed
Plan: 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
count shifts indices when a middle item is removed; for_each leaves every other key untouched

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.

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 above with subnetsRequires 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 sizesEvery 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
  • “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_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 azurerm_linux_virtual_machine.monitoring[0] safely even when count might be 0.” Proven above: this errors the moment count evaluates to 0. one(azurerm_linux_virtual_machine.monitoring[*].id) is the safe form.
  • “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.
  • “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) — one(some_resource.name[*].attr) is the fix, proven above.
  • for_each keyed on a list index converted to a string when the underlying collection has genuinely meaningful keys available (names, roles) — this reintroduces count’s fragility with extra syntax.
  • for_each over 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, immediate plan-time error.
  • 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) 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.

What happens when you remove an item from the middle of a list used with count
Why does for_each avoid the index-shifting problem that count has
What is the difference between a for expression and the for_each resource meta-argument
How is a specific instance created with for_each addressed outside its resource block
What happens when you run terraform plan against this?
resource "random_pet" "names" {
  count = 2
}

resource "azurerm_linux_virtual_machine" "web" {
  for_each = toset(random_pet.names[*].id)
}
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?