Count and For Each
The idea in one sentence
Section titled “The idea in one sentence”count and for_each both create more than one copy of a resource from a single block, but they address those copies in fundamentally different ways, and that difference decides how safely you can add or remove one later.
count — a numeric meta-argument
Section titled “count — a numeric meta-argument”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 gives the current copy’s position; outside, each copy is addressed by that same numeric index.
variable "instance_count" { type = number description = "Number of Compute Engine instances to launch" default = 3}
resource "google_compute_instance" "web" { count = var.instance_count name = "web-${count.index}" machine_type = "e2-medium" zone = "us-central1-a"
boot_disk { initialize_params { image = "debian-cloud/debian-12" } }
network_interface { network = "default" access_config {} }}With instance_count = 3, Terraform creates google_compute_instance.web[0], web[1], and web[2]. Each is addressed externally by that bracketed index — google_compute_instance.web[0] refers specifically to the first copy, web[1] to the second, and so on.
The index-shifting foot-gun
Section titled “The index-shifting foot-gun”count’s real weakness is that a copy’s identity is nothing more than its position in the list. Suppose instead of a plain number, count is driven by a list of names:
variable "instance_names" { type = list(string) description = "Names of Compute Engine instances to launch" default = ["web-a", "web-b", "web-c"]}
resource "google_compute_instance" "web" { count = length(var.instance_names) name = var.instance_names[count.index] machine_type = "e2-medium" zone = "us-central1-a"
boot_disk { initialize_params { image = "debian-cloud/debian-12" } }
network_interface { network = "default" access_config {} }}Here web[0] is "web-a", web[1] is "web-b", and web[2] is "web-c". Now remove "web-b" from the middle of the list, leaving ["web-a", "web-c"]. Terraform does not understand that "web-b" specifically was removed — all it sees is that index 1 now maps to a different name than before, and index 2 no longer exists.
Proven with count over local_file (the exact index-shifting mechanism, no GCP project needed to see it — google_compute_instance itself needs a real project even to plan, which this course never has):
$ 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", and index 2 is destroyed outright with nothing to replace it. One item removed from a three-item list produced 1 create + 2 destroys — every resource whose index shifted gets touched, purely because it moved position in the list.
for_each — addressed by stable key, not position
Section titled “for_each — addressed by stable key, not position”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 index.
variable "instances" { type = map(string) description = "Map of instance key to machine type" default = { primary = "e2-medium" secondary = "e2-small" batch = "e2-standard-2" }}
resource "google_compute_instance" "web" { for_each = var.instances name = "web-${each.key}" machine_type = each.value zone = "us-central1-a"
boot_disk { initialize_params { image = "debian-cloud/debian-12" } }
network_interface { network = "default" access_config {} }}Each instance is addressed externally as google_compute_instance.web["primary"], web["secondary"], and web["batch"]. Inside the resource, each.key is the map key and each.value is its associated value.
Removing a key entirely (rather than renaming something) is unaffected by the mock_provider caveat above — it only involves state entries disappearing from the desired set, not an in-place-vs-replace judgment call. Proven with the equivalent per-key example further down this lesson (real terraform test, google_compute_subnetwork): removing one key destroys exactly that key’s resource and nothing else. web["primary"] and web["batch"] here are keyed by their own names, not by position, so they are completely undisturbed by a key going away in the middle.
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), 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 — exactly the instances variable above, where each.value supplies each instance’s machine_type.
Proven with the local_file equivalent (content = "instance ${each.key} has type ${each.value}") after apply:
$ cat out-primary.txtinstance primary has type e2-medium$ cat out-batch.txtinstance batch has type e2-standard-2each.key names the instance, each.value carries its per-instance configuration — the shape to 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. Referencing that resource elsewhere breaks the moment count is 0: google_compute_instance.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 "google_compute_instance" "monitoring" { count = var.enable_monitoring ? 1 : 0 name = "monitoring" machine_type = "e2-medium" zone = "us-central1-a"
boot_disk { initialize_params { image = "debian-cloud/debian-12" } }
network_interface { network = "default" access_config {} }}
output "monitoring_instance_id" { value = one(google_compute_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; 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 "google_compute_instance" "web" { for_each = toset(random_pet.names[*].id) # ...}Real error (proven with the local_file equivalent — the mechanism is core Terraform, identical regardless of resource type or provider):
$ 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, a zone 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.A real per-key subnetwork example
Section titled “A real per-key subnetwork example”Putting for_each to work the way it actually gets used: one subnetwork per name, keyed so adding or removing a subnet never disturbs the others. Proven with terraform test and mock_provider "google" {}, since a real plan needs a real GCP project 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 "google_compute_network" "main" { name = "vpc-demo" auto_create_subnetworks = false}
resource "google_compute_subnetwork" "this" { for_each = var.subnets name = each.key ip_cidr_range = each.value region = "us-central1" network = google_compute_network.main.id}Real terraform test run: create all three, then remove "app" from the map and plan again:
mock_provider "google" {}
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 # google_compute_subnetwork.this["app"] will be destroyedPlan: 0 to add, 0 to change, 1 to destroy.Success! 2 passed, 0 failed.Exactly google_compute_subnetwork.this["app"] is destroyed — web and db never appear in the plan at all, because their keys never moved. Unlike the name-rename case flagged above, this proof only involves a key disappearing from the desired set, which is ordinary state reconciliation, not a ForceNew judgment call — so it is trustworthy as shown.
for expressions — comprehension, not a resource meta-argument
Section titled “for expressions — comprehension, not a resource meta-argument”A for expression is a general-purpose comprehension that transforms one collection into another — it has nothing to do, by itself, with creating multiple resources. It just happens to share the word “for” with for_each, which trips people up.
variable "instance_names" { type = list(string) description = "Names of Compute Engine instances" default = ["web-a", "web-b", "web-c"]}
locals { # for expression producing a list: transform each name to uppercase upper_names = [for name in var.instance_names : upper(name)]
# for expression producing a map: build a lookup from name to a derived label name_to_label = { for name in var.instance_names : name => "label-${name}" }}[for name in var.instance_names : upper(name)] is a for expression that produces a new list. { for name in var.instance_names : name => "label-${name}" } is a for expression that produces a new map. Neither one is the for_each meta-argument on a resource block — a for expression transforms data you already have into different data, while the for_each meta-argument on a resource decides how many instances of that resource to create and what key addresses each one. It is entirely normal, and common, to use a for expression to build the map you then hand to a resource’s for_each — the google_compute_subnetwork.this example above could just as easily have its subnets map built with a for expression from a flatter input variable.
flowchart TB
subgraph count_case["count with a list"]
direction LR
c0["web[0] = web-a"] --> c1["web[1] = web-b"] --> c2["web[2] = web-c"]
c2 -.remove web-b.-> shift["web[1] destroyed and recreated, web[2] destroyed"]
end
subgraph foreach_case["for_each with a map"]
direction LR
f0["web[primary]"] --- f1["web[secondary]"] --- f2["web[batch]"]
f1 -.remove secondary.-> untouched["only web[secondary] destroyed, primary and batch undisturbed"]
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 zone, a map key you chose) — resilient, because removing one thing never renames another. Every proof above is a consequence of that one distinction, including the mock_provider caveat: a rename-in-place-vs-replace question depends on the resource’s own schema and how faithfully the test tooling honors it, but a key disappearing from a for_each set never depends on that at all — it is just “this key is no longer desired,” resolved by Terraform core regardless of provider.
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 local_file plan diff |
for_each (set) | Removing one key destroys only that resource — proven above with subnetworks | 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-instance machine types | 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
google_compute_instance.monitoring[0]safely even whencountmight be0.” Proven above: this errors the momentcountevaluates to0.one(google_compute_instance.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. - “
terraform testwithmock_provideralways predicts the real provider’s replace-vs-update behavior correctly.” Not confirmed above — in fact contradicted:google_compute_instance.namehasForceNew: truein the real provider schema, but a baremock_provider "google" {}run changing onlynameplanned an in-place update. Uselocal_file(oroverride_resourcewith an explicitcomputed/plannedshape) for mechanism proofs, and don’t trustmock_provider’s replace/update classification for a specific provider resource without checking the schema too. - “
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 when the underlying collection has genuinely meaningful keys available (names, zones) — 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 generated
google_compute_instanceblock with nonetwork_interfaceblock at all — this is not optional;terraform validaterejects it becausenetwork_interfaceis a required block, not a computed one. - Code that trusts a
mock_provider-basedterraform testplan diff to proveForceNewbehavior for a real provider resource — proven above this can be wrong; verify against the provider’s own schema/docs for anything that matters operationally.
🐛 Spot the bug
variable "instance_names" {type = list(string)default = ["web-a", "web-b", "web-c"]}
resource "google_compute_instance" "web" {count = length(var.instance_names)name = var.instance_names[count.index]machine_type = "e2-medium"zone = "us-central1-a"
boot_disk { initialize_params { image = "debian-cloud/debian-12" }}
network_interface { network = "default" access_config {}}}
# Later: ops wants to remove "web-b" from the deploy# variable "instance_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 instance. Proven above with the identical shape (local_file instead of google_compute_instance, same core 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.instance_names) (or a map, if per-instance config beyond the name is needed): removing "web-b" then plans exactly 1 to destroy, nothing else touched.