Chapter 6: Iteration and Conditionals
Topic 37

count vs for_each

Comparison

Almost every "I need several of these" decision comes down to count versus for_each, and getting it wrong is not a style nit — it determines whether editing the collection later is a no-op diff or a destructive renumber. The rule fits in a sentence: use count for an ordered set of interchangeable replicas, use for_each for a keyed set of distinct things, and treat for_each as the default because most real collections are keyed.

The two previous topics covered each construct in isolation. This one is about the choice between them, the one decision that has the largest downstream consequence in this chapter. It also covers the unpleasant case you will eventually face: an existing count collection that should have been for_each from the start, and how to convert it without an outage.

Positional vs Keyed

count addresses instances by index — [0], [1], [2]. for_each addresses them by key — ["billing"], ["orders"]. That address is not a display detail; it is the identity Terraform records in state and uses to match config against reality on the next plan. The whole comparison reduces to this: one identity scheme is a position that shifts, the other is a key that does not.

Removing one element from a collection of three
count — positional
Addressed [0], [1], [2]. Drop the middle and the tail renumbers, so Terraform churns every later instance with destroy-and-recreate.
for_each — keyed
Addressed ["billing"], ["orders"]. Drop one key and only that instance is destroyed; every other address stays put. Surgical add and remove.

What Changes When the Collection Changes

Under count, removing a non-last element shifts every later index down by one, and because the address is the identity, Terraform reads each shifted position as a different resource — it churns the tail with destroys and recreates. Under for_each, removing an entry touches only that key; every other instance keeps its address and its state untouched. This single behavioral difference decides most cases before you even reach the heuristic.

For the Hatch pipeline this is the difference between safe and dangerous. The source list, the event-type list, and the consumer list all grow and shrink over time. Keyed by name under for_each, every add and remove is surgical. Keyed by position under count, removing the billing source from the middle would destroy and recreate every bucket after it, losing their contents.

The Decision Heuristic

Ask one question. "Are these N interchangeable copies where order is meaningless and stable?" — then count. "Are these distinct things I identify by name?" — then for_each. When you are unsure, choose for_each; the cost of for_each on a collection that turns out to be fixed is nothing, while the cost of count on a collection that turns out to be keyed is a destructive renumber the first time someone edits it.

Converting count to for_each

Switching an existing counted resource to for_each re-addresses every instance from [0] to ["key"]. Terraform sees the old addresses in state and the new addresses in config, finds no match, and reads it as destroy-everything-and-recreate — unless you tell it the addresses are the same resource under a new name. You do that by remapping, either with moved blocks in the configuration or with terraform state mv on the command line.

A moved block per instance makes the conversion a no-op
moved {
  from = google_pubsub_topic.events[0]
  to   = google_pubsub_topic.events["orders"]
}

moved {
  from = google_pubsub_topic.events[1]
  to   = google_pubsub_topic.events["clicks"]
}

The Migration Mechanics

A moved block per instance — from = google_pubsub_topic.events[0] to to = google_pubsub_topic.events["orders"] — tells Terraform the resource at the old address is the same object now living at the new one, so the plan shows zero changes. Without it, swapping a loop construct from count to for_each takes a full outage as every topic, bucket, and account is destroyed and recreated, which is an absurd price for what is mechanically a refactor. Write the moved blocks, confirm the plan reports no destroys, then apply.

count vs for_each

count — keys instances by integer position, so it fits a fixed number of identical replicas but destroys and recreates the tail when a middle element is removed. Choose it only for genuinely ordered, interchangeable copies where membership never changes in the middle.

for_each — keys instances by a stable map or set key, so insertions and removals are surgical and reference addresses stay put. Choose it for everything else, which is most things — any collection of distinct, named resources you might later add to or trim.

Common Mistakes
  • Defaulting to count for a list of named resources, then taking destructive churn the first time the list is reordered or trimmed in the middle.
  • Converting count to for_each without moved blocks and watching the plan propose destroying every existing instance to recreate it under a new address.
  • Using count to "conditionally" pick between two distinct resources by index, encoding identity in a position that shifts the moment the config changes.
  • Mixing both constructs for the same logical collection across modules, so half the fleet is keyed and half is positional and neither migrates cleanly.
  • Assuming the choice is cosmetic and reversible at no cost — the state addressing differs, so swapping them is a state-surgery operation, not a free edit.
  • Running the converted apply without reading the plan, trusting that a loop-construct swap is harmless, and discovering the destroys only after they have happened.
Best Practices
  • Treat for_each as the default and justify count only for fixed, ordered, interchangeable replicas.
  • When converting count to for_each, write one moved block per instance so the plan shows zero changes.
  • Never encode resource identity in a list position that can shift; encode it in a stable key.
  • Review the plan for unexpected destroy and create after any change to a counted collection — the renumber is silent until you read the plan.
  • Pick the construct once per collection and keep it consistent across every module that touches that collection.
Comparable approaches Pulumi real-language loops over typed collections sidestep the distinction Config Connector no loop primitive, one manifest each CloudFormation Count vs Fn::ForEach Ansible loop, with no persistent positional state to renumber

Knowledge Check

What is the core difference that drives the count-versus-for_each choice?

  • count identifies instances by shifting position, for_each by a stable key, and that address is the identity in state
  • count is faster to apply because its numeric positions sort and converge in fewer graph-walk passes
  • for_each supports a strictly higher per-block instance ceiling than count does before Terraform refuses to expand it
  • count accepts maps and sets, while for_each works only with ordered lists

You convert a counted resource to for_each with no moved blocks. What does the plan show?

  • Every existing instance destroyed and recreated, because the state addresses no longer match the config
  • No changes at all, because Terraform automatically maps the old numeric indices onto the new string keys for you
  • Only the instances whose attribute values actually changed are destroyed and recreated
  • A warning, but the apply proceeds with the addresses untouched

What does a moved block accomplish in a count-to-for_each conversion?

  • It tells Terraform the old address and the new address are the same object, so the plan reports zero changes
  • It snapshots the resource into state so that it can be rolled back automatically if the conversion apply fails partway through
  • It pauses the resource and holds it offline while the state address is rewritten
  • It converts the list keys into map keys automatically at apply time

Why is for_each the safer default when you are unsure?

  • for_each on a fixed collection costs nothing, while count on a keyed collection churns it destructively on the first edit
  • for_each applies measurably faster on small collections because keyed lookups skip the index scan
  • for_each can always be converted straight back to count later for free, with no plan churn and no destroy-and-recreate at all
  • count is deprecated and slated for removal in an upcoming Terraform release

You got correct