count vs for_each
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.
[0], [1], [2]. Drop the middle and the tail renumbers, so Terraform churns every later instance with destroy-and-recreate.["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.
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 — 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.
- Defaulting to
countfor a list of named resources, then taking destructive churn the first time the list is reordered or trimmed in the middle. - Converting
counttofor_eachwithoutmovedblocks and watching the plan propose destroying every existing instance to recreate it under a new address. - Using
countto "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.
- Treat
for_eachas the default and justifycountonly for fixed, ordered, interchangeable replicas. - When converting
counttofor_each, write onemovedblock 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.
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_eachworks 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_eachon a fixed collection costs nothing, whilecounton a keyed collection churns it destructively on the first editfor_eachapplies measurably faster on small collections because keyed lookups skip the index scanfor_eachcan always be converted straight back tocountlater for free, with no plan churn and no destroy-and-recreate at allcountis deprecated and slated for removal in an upcoming Terraform release
You got correct