Chapter 6: Iteration and Conditionals
Topic 36

for_each

Iteration

for_each stamps out one resource instance per entry in a map or a set of strings, addressing each by its key rather than its position: google_storage_bucket.raw["billing"], not [1]. Inside the block you read each.key and each.value, and because the key is stable, adding or removing one entry touches only that entry. That single property is why for_each is the default choice for almost every collection of resources.

This is the construct that lets the Hatch pipeline fan out cleanly. One raw bucket per source system, one Pub/Sub topic per event type, one service account per consumer — each is a for_each over a map, keyed on a human-meaningful name. The keying is not cosmetic: it is what makes a later edit to the collection a surgical one-line diff instead of the destructive renumber that count produces.

Map and Set Inputs

for_each accepts two input types: a map(...) mapping each key to a config object, or a set(string) of bare names. It deliberately does not accept a list, because a list has an order and for_each is order-free by design — feed it a list and Terraform errors. To iterate a list you wrap it in toset() first, which discards the ordering on purpose and gives you a set of unique members.

A set of strings — one topic per event type
resource "google_pubsub_topic" "events" {
  for_each = toset(["orders", "clicks", "billing"])
  name     = "hatch-events-${each.key}"   # each.value == each.key for a set
}

each.key and each.value

Inside the block, each.key is the map key (or the set member), and each.value is the map value (or, for a set, identical to the key). You build each instance's name, region, and labels from these two. For a set, the only thing available is the string itself, exposed as both each.key and each.value; for a map of objects, each.value is the whole object and you reach into its fields.

A map of objects — per-entry settings
variable "sources" {
  type = map(object({
    location = string
    labels   = map(string)
  }))
}

resource "google_storage_bucket" "raw" {
  for_each = var.sources
  name     = "hatch-events-raw-${each.key}"   # key is the source name
  location = each.value.location           # value carries the settings
  labels   = each.value.labels
}
Two shapes you can feed for_each
set(string)
The key is the whole config — one topic per name, nothing else varies. each.value equals each.key. Pick when only the name differs.
map(object)
Each entry carries its own settings — location, labels, retention. each.value is the object; you reach into its fields. Pick when instances differ by more than their name.

Stable Keys Survive Edits

Instances are keyed by the map key, so the address Terraform tracks in state is google_storage_bucket.raw["billing"] — tied to the name, not a position. Insert "events-billing" into the map and Terraform creates exactly that one bucket; remove "events-legacy" and it destroys exactly that one. Every other bucket, topic, and dataset is untouched, because none of their keys moved. This is the precise opposite of count, where removing a middle element renumbers and churns the tail.

The payoff is that the Hatch source list can grow and shrink over the pipeline's life with no collateral damage. Onboarding a new source system is adding one map entry; retiring one is deleting an entry. Neither operation ever proposes destroying a bucket you did not name.

The Canonical Pattern

A for_each over a map of objects is the workhorse of real configurations. It is how Hatch creates one Pub/Sub topic per event type, one BigQuery dataset per tenant, or a map of service accounts in a single block — each entry's per-instance settings carried in the value object. One block, one collection variable, and the entire fan-out lives in the map rather than in copy-pasted resource blocks.

A map of service accounts, one per consumer
resource "google_service_account" "consumer" {
  for_each     = var.consumers
  account_id   = "hatch-${each.key}"
  display_name = each.value.display_name
}

Constraints

The keys — and set members — must be known at plan time, not derived from a computed attribute. for_each over a value that is only knowable after apply (a generated id, another resource's output) errors, because Terraform must expand the instances during plan to show you what it will do. This is the same plan-time rule that governs count: the structure of the collection is fixed before anything is created. The values inside each entry can be computed; the keys cannot.

set(string) vs map(object)

set(string) — right when the key is the entire config: one topic per name and nothing else varies. each.value equals each.key, so there is nothing richer to read. Wrap a list in toset() to get here, accepting that you discard order.

map(object) — right when each entry carries its own settings: region, labels, retention. each.value is the object and you reach into its fields. Reach for the map the moment instances differ by more than their name, which is most of the time.

Common Mistakes
  • Passing a list(string) to for_each and getting a type error — wrap it in toset(), and understand you are discarding order on purpose.
  • Using a computed or apply-time value as a for_each key (a generated id, another resource's output) — keys must be known at plan, or Terraform refuses to expand the resource.
  • Building keys from values that collide after normalization (two entries that both map to "prod") — duplicate keys either silently drop one entry or error.
  • Choosing a volatile key like an index position or a timestamp, reintroducing the very renumbering churn for_each exists to avoid.
  • Forgetting that for a set, each.value equals each.key — reaching for a richer each.value that does not exist and getting just the string back.
  • Keying the map on a field that is convenient but unstable, so a routine rename of that field destroys and recreates the instance under its new key.
Best Practices
  • Default to for_each for any collection of distinct, named resources — buckets per source, topics per event type, a map of service accounts.
  • Key the map on a stable, human-meaningful identifier like the source name, never on a position or a computed id.
  • Use a map(object) when entries carry per-instance settings, and a toset() of strings only when the name is the entire config.
  • Keep all for_each keys plan-time-knowable; derive them from variables and locals, not from resource outputs.
  • Treat the map key as the resource's permanent identity, and renumber inputs by changing values rather than reshuffling keys.
Comparable approaches Pulumi a real-language loop building keyed resources Config Connector one manifest per resource, no native fan-out Ansible loop over a dict CloudFormation Fn::ForEach as the closest native analog

Knowledge Check

Why does removing one entry from a for_each map not churn the other instances?

  • Each instance is keyed by its stable map key, so only the removed key's instance is destroyed
  • Terraform recreates the whole collection but restores the surviving instances from a state backup
  • The instances are renumbered, but Terraform suppresses the resulting diff
  • Maps preserve insertion order, so later entries keep their positions

What is each.value when for_each iterates a set(string)?

  • Identical to each.key — the set member string itself, with nothing richer behind it
  • An object exposing the member's zero-based index within the set along with the set's total length
  • Null, because a set exposes only each.key and never populates the value
  • The position the member held in the original pre-toset list

Why must a list(string) be wrapped in toset() before passing it to for_each?

  • for_each rejects lists because they carry order, and it deliberately keys by member rather than position
  • Lists are immutable in HCL, and for_each needs to mutate the collection in place as it expands into instances
  • toset() sorts the list so the instances apply in a deterministic order
  • A list cannot hold plain strings as members, whereas a set can

Which value can for_each legitimately depend on?

  • A map defined from variables and locals, whose keys are known at plan time
  • A set of ids generated by another resource during apply
  • A computed attribute of another resource created earlier in the very same apply run
  • A random string produced at apply time to guarantee uniqueness

You got correct