Skip to content

Count and For Each

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

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.txt
instance primary has type e2-medium
$ cat out-batch.txt
instance batch has type e2-standard-2

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

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

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:

tests/removal.tftest.hcl
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 destroyed
Plan: 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
count shifts every index after a mid-list removal; for_each only touches the removed 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, 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.

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 local_file plan diff
for_each (set)Removing one key destroys only that resource — proven above with subnetworksRequires 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 typesEvery 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 google_compute_instance.monitoring[0] safely even when count might be 0.” Proven above: this errors the moment count evaluates to 0. one(google_compute_instance.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.
  • “terraform test with mock_provider always predicts the real provider’s replace-vs-update behavior correctly.” Not confirmed above — in fact contradicted: google_compute_instance.name has ForceNew: true in the real provider schema, but a bare mock_provider "google" {} run changing only name planned an in-place update. Use local_file (or override_resource with an explicit computed/planned shape) for mechanism proofs, and don’t trust mock_provider’s replace/update classification for a specific provider resource without checking the schema too.
  • “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 when the underlying collection has genuinely meaningful keys available (names, zones) — 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 generated google_compute_instance block with no network_interface block at all — this is not optional; terraform validate rejects it because network_interface is a required block, not a computed one.
  • Code that trusts a mock_provider-based terraform test plan diff to prove ForceNew behavior 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.

What specifically goes wrong when you remove an item from the middle of a list driving a count-based resource
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 an individual resource instance addressed when using for_each with a map
What happens when you run terraform plan against this?
resource "random_pet" "names" {
  count = 2
}

resource "google_compute_instance" "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?
A terraform test run using mock_provider "google" {} (no override_resource) shows google_compute_instance.name changing as an in-place update, even though the real provider schema marks name as ForceNew. What does that tell you?
What does terraform_data add over the older null_resource, besides not needing a separate provider?