Home › Cloud › Terraform count vs for_each: Stop Index Shifts Eating VMs
Intermediate 5 min · September 23, 2026

Terraform count vs for_each: Stop Index Shifts Eating VMs

Count addresses by position, so reorders destroy survivors.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Everything here is grounded in real deployments.

Follow
✓ Production
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 12 min
  • ✓You've used count to create multiple similar resources
  • ✓Comfort with maps, sets, and basic for expressions in HCL
  • ✓A non-production workspace where you can practice a migration
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • count numbers instances by position (web[0], web[1]), so reordering or deleting mid-list renumbers survivors into replacements.
  • for_each keys instances by stable identity (web["a"]), so only genuinely added, removed, or changed keys are touched.
  • Migrate safely with moved blocks mapping each index to its key, and demand an empty plan before applying.
  • Key by attributes that never change (hostname, username); keep mutable settings in the value.
✦ Definition~90s read
What is Terraform count vs for_each?

count and for_each are Terraform's two repetition mechanisms, and they differ in how they identify the instances they create. count creates N copies addressed by position — web[0] through web[N-1] — where the address means 'the item currently at this slot.' for_each creates one instance per key in a map or set — web["a"], web["b"] — where the address means 'the item with this identity.' That addressing difference is the entire story: positions shift when collections change shape, keys don't.

★
Imagine a parking lot where cars are tracked by space number.

Under count, every structural edit to the backing list renumbers addresses: reorders reassign all of them, mid-list deletions shift everything after the gap, insertions push later items up. Terraform diffs desired addresses against state and treats each changed address as a different object — destroy the old, create the new — even when the underlying items are untouched.

Under for_each, the same edits add, remove, or update only the affected keys; surviving keys keep their addresses and their infrastructure.

moved blocks complete the picture as Terraform's address-rename mechanism: they declare that the object at one address is the same object now living at another, so Terraform updates its state bookkeeping instead of replacing infrastructure. They're the safe bridge from count to for_each (and for any refactor that renames addresses), verified by the empty plan that proves the move was pure bookkeeping.

Together, the three constructs form one coherent identity model — and most count disasters are identity-model mistakes, not syntax errors.

Plain-English First

Imagine a parking lot where cars are tracked by space number. When the first car leaves and everyone shuffles forward, the attendant's log says cars 1 through 10 are all 'different cars' — even though only one left. That's count. for_each is license plates instead: each car keeps its identity no matter where it parks, so the log only changes for cars that truly arrived or left. Moved blocks are the afternoon the lot switches systems, carefully recording that space 3's car is really plate XYZ.

Someone alphabetizes a list of server names — a cosmetic cleanup, the kind of commit that shouldn't even need a review — and Terraform responds by proposing to destroy half your fleet. Not because anything about those servers changed, but because count addresses instances by position: web[0], web[1], web[2]. Rename the first entry and every survivor gets renumbered, and renumbering looks exactly like replacement.

This is the count trap, and nearly every Terraform team walks into it. Count is the first loop you learn, so it becomes the default for everything: servers, DNS records, IAM users. It works beautifully until the day the list changes shape — a reorder, a mid-list deletion, a sort — and then it bills you in destroyed databases and rotated IP addresses.

for_each is the exit: it addresses instances by stable keys instead of positions, so edits touch only what actually changed. This article shows you why index shifts destroy, how to migrate live infrastructure with moved blocks and zero downtime, and how to pick keys that stay stable for the lifetime of your resources.

Why Reordering a List Destroys Servers Under count

Count addresses instances by position in a resource array: web[0] is whatever item zero currently holds, not a specific server. When the backing list reorders — an alphabetical sort, a new item inserted at the top, a mid-list deletion — every affected index points at a different item than before. Terraform compares desired addresses against state, finds every shifted address holding the 'wrong' object, and plans destroy-plus-create for each one. The servers never changed; their numbers did.

Mid-list deletion is the cruelest variant. Remove item zero from a five-item list and items one through four slide down a slot: four instances destroyed and recreated to delete one server. Appends at the end are the only safe mutation, and even those survive only until someone sorts the list or inserts above them. Every count-backed list is one well-meaning edit away from a fleet replacement plan.

This is why experienced teams treat count as a specialist tool for fixed-size, interchangeable sets — three identical zone workers, two NAT gateways — and never for named things with identity. If members have names, meanings, or individual lifecycles, positional addressing is a mismatch that will eventually bill you. The plan output is your early warning: destroys on resources you didn't touch mean position just masqueraded as identity.

count-trap.tfHCL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# BEFORE: position is identity — sorting this list renumbers every instance
variable "server_names" {
  type    = list(string)
  default = ["web-c", "web-a", "web-b"]
}

resource "aws_instance" "web" {
  count         = length(var.server_names)
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"

  tags = {
    Name = var.server_names[count.index]
  }
}
# Sorting the default destroys web[0..2] and creates three 'new' servers
📊 Production Insight
In the sort incident, the plan proposed replacing all 12 servers from a one-file variable edit — the signature destroy pattern that now triggers an automatic review hold on that team.
🎯 Key Takeaway
Count addresses are positional — any reorder or mid-list deletion renumbers survivors into destroy-and-recreate plans.

for_each Keys: Identity That Survives Reorders and Deletions

for_each replaces positions with keys: web["web-a"] is the server named web-a regardless of how the collection is ordered, what was added, or what was removed. Adding a key creates one instance; removing a key destroys exactly one; editing a value updates one in place. Neighbors are never renumbered because there are no numbers — identity travels with the key, immune to every edit that devastates count.

The key insight is the split between identity and configuration. The key declares which object this is; the value declares how it's configured. Changing the value (instance type, tags, AMI) updates the same object in place, while changing the key addresses a different object entirely. This is why key choice matters so much: keys must be the stable, immutable identity (hostname, username, bucket name), and everything mutable belongs in the value.

Conversion starts at the input: lists must become maps or sets before for_each accepts them. The standard transform is { for u in var.users : u.name => u }, keying each element by its stable attribute. Keep that transform beside the resource so reviewers see the key source, and prefer maps over sets when values carry per-instance settings — sets of strings work for uniform fleets, maps of objects for heterogeneous ones.

for-each-stable.tfHCL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# AFTER: identity is the key — order no longer matters
variable "servers" {
  type = map(object({
    instance_type = string
  }))
  default = {
    "web-a" = { instance_type = "t3.micro" }
    "web-b" = { instance_type = "t3.micro" }
    "web-c" = { instance_type = "t3.small" }
  }
}

resource "aws_instance" "web" {
  for_each      = var.servers
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = each.value.instance_type

  tags = {
    Name = each.key
  }
}
# Sorting, adding, or removing keys touches only the affected instances
📊 Production Insight
After migrating, the team re-applied the alphabetical sort that once threatened the fleet — and got an empty plan. The same edit went from fleet-destroyer to no-op.
🎯 Key Takeaway
Keys are identity, values are configuration — key by the immutable name and let edits touch only what truly changed.

Migrating Live Fleets With moved Blocks and Zero Downtime

Migrating live infrastructure from count to for_each without moved blocks is a self-inflicted outage: Terraform sees indexed addresses vanish and keyed addresses appear, and plans destroy-all plus create-all. Moved blocks prevent that by declaring continuity — the object at aws_instance.web[0] is the same object as aws_instance.web["web-a"] — so Terraform remaps state instead of replacing infrastructure. One block per instance, each mapping the old index to its new key.

The migration ritual has four steps. First, convert the resource to for_each with keys matching current reality (index zero's actual name becomes the first key — verify against state, not assumptions). Second, write the moved blocks. Third, run plan and demand emptiness: a clean plan proves every mapping is correct and the migration touches zero infrastructure. Fourth, apply the pure remap, then remove the moved blocks in a later cleanup once all branches and environments have applied them.

If the plan shows any destroy or create, stop — a mapping is wrong. Compare each from address against terraform state list output and each to key against the live object's real name. Common culprits: assuming index order matches name order (verify!), or keying by an attribute that differs from reality. The empty plan is the entire safety case; never waive it.

migration.tfHCL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Declare every address rename: index -> key, one block each
moved {
  from = aws_instance.web[0]
  to   = aws_instance.web["web-a"]
}

moved {
  from = aws_instance.web[1]
  to   = aws_instance.web["web-b"]
}

moved {
  from = aws_instance.web[2]
  to   = aws_instance.web["web-c"]
}

# Prove it: expect 'No changes' — pure state remap, zero new servers
📊 Production Insight
The fleet migration's first plan showed one create — index 2's assumed name didn't match reality. That single plan read caught the error that would have replaced a production database host.
🎯 Key Takeaway
One moved block per instance from index to key, then an empty plan as proof — any destroy in the plan means a mapping is wrong.

Fixing Downstream References That Still Think Positionally

The migration doesn't end at the resource — every downstream reference must convert from positional to keyed thinking. Splat expressions and numeric indexes over the old count resource (aws_subnet.main[count.index]) silently follow positions, not machines; after a reorder they attach to the wrong instances with no error. Convert each one: iterate the keyed map directly (for_each = aws_instance.web) or look up by key (aws_instance.web["web-a"].id).

Outputs need the same treatment. A list output built positionally (web_ids[0]) becomes meaningless once keys exist — downstream modules indexing it positionally inherit the original fragility. Convert outputs to maps keyed identically ({ for k, inst : k => inst.id }), and update every consumer to look up by key. This is the tedious half of the migration and the half most often skipped, which is why 'migrated' fleets sometimes keep their old ordering bugs in downstream attachments.

Audit mechanically: grep for the old resource name across all files and convert every hit, then plan and read downstream diffs for attachments landing on unexpected instances. The rule going forward is absolute — never index a for_each resource positionally. Keys exist precisely so positions don't matter; any code that reintroduces positional assumptions rebuilds the trap you just escaped.

keyed-references.tfHCL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Before: positional downstream breaks on every reorder
# subnet_id = aws_subnet.main[count.index].id

# After: look up by the same stable key
resource "aws_eip" "web" {
  for_each = aws_instance.web
  instance = each.value.id

  tags = {
    Name = "eip-${each.key}"
  }
}

output "web_ids" {
  value = { for k, inst in aws_instance.web : k => inst.id }
}
📊 Production Insight
A 'completed' migration kept misattaching EIPs for weeks because one downstream module still indexed positionally. The grep audit that found it is now a required migration step.
🎯 Key Takeaway
Convert every splat, index, and positional output to keyed lookups — downstream positional code preserves the original bug.

Choosing Keys That Stay Stable for the Life of the Resource

Key selection is the decision that outlives the migration, because keys become permanent identity: changing a key later replaces the instance. The rule is simple to state and requires judgment to apply — key by the attribute that will never change over the object's lifetime. Hostnames for servers, usernames for IAM users, bucket names for buckets. These are the names the real world already uses as identity, which is exactly why they make stable keys.

The failure mode is keying by something mutable: an environment tag that gets renamed, a role that evolves, a size label that changes. Every such key is a future replacement hiding in plain sight — the day the attribute changes, Terraform sees a new key and destroys the instance. Review key sources with replacement-colored glasses: if this string could ever legitimately change, it must live in the value, not the key.

Enforce this at review time with two questions for every for_each: what is the key, and what happens when it changes? If the answer to the second is 'replace,' confirm that's intended (sometimes it is — immutable infrastructure welcomes it). Write the key-source rationale in a comment beside non-obvious transforms, so the engineer editing values in two years doesn't accidentally promote a mutable attribute into identity.

📊 Production Insight
One team keyed instances by role label, then renamed 'worker' to 'compute' and replaced the fleet. The postmortem rule — keys are birth names, never job titles — stuck better than any documentation.
🎯 Key Takeaway
Keys must be lifetime-immutable identity; everything mutable belongs in the value — review every key by asking what happens when it changes.

Count Still Has a Job: Fixed Interchangeable Sets

After five sections on count's dangers, a defense is overdue: count isn't broken, it's specialized. Its home turf is fixed-size sets of interchangeable things — two NAT gateways for zone redundancy, three identical workers spread across availability zones, a replica number that scales as one value. Here no member has individual meaning: gateway zero and gateway one differ only by position, and if one is replaced, no identity is lost. Positional addressing fits because position is all there is.

The dividing line is meaning. Ask whether members have names that outlive the deployment: servers with hostnames, users with logins, buckets holding data — all carry identity, all deserve for_each. Ask whether the size changes by editing a list humans reorder: any yes means keys, not indexes. Count survives these questions only when the answer is genuinely three of the same and you don't care which is which. NAT gateways pass; application servers don't.

Keeping count healthy in its niche takes two habits. First, never let humans edit the backing structure positionally — derive counts from numbers (count = 3) or length() of machine-generated lists, not from hand-ordered name lists. Second, keep downstream references positional only where the resource is positional: subnet lookups by zone index are fine when zones are fixed, but the moment instances gain names, the whole chain should go keyed. Respect the boundary and both constructs behave; cross it and you're back in index-shift territory.

count-proper.tfHCL
1
2
3
4
5
6
# count's home turf: fixed, interchangeable, unnamed
resource "aws_nat_gateway" "main" {
  count         = 2
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id
}
🔥Count Still Has a Job
Count isn't evil — it's specialized. Fixed-size interchangeable sets (three identical zone workers, two NAT gateways) are count's home turf, where no member has individual meaning. The mistake is defaulting to count for named things. Match the tool to the identity model and both constructs behave.
📊 Production Insight
A team that audited every count in their codebase found nine correct uses (gateways, replicas, zone workers) and fourteen latent traps — one afternoon of work that prevented the next three incidents.
🎯 Key Takeaway
Count is for fixed interchangeable sets derived from numbers, not hand-ordered lists — if members have names, use for_each.
● Production incidentPOST-MORTEMseverity: high

The Alphabetical Sort That Tried to Destroy Twelve Servers

Symptom
Tuesday's 'cleanup' pull request sorted var.server_names alphabetically. The CI plan came back proposing to destroy 12 production EC2 instances and create 12 new ones — new IPs, new EBS volumes, total fleet replacement — from a commit that touched one variable file and zero resource blocks.
Assumption
The cleanup was reviewed as cosmetic — variable files don't change infrastructure, or so everyone believed. The reviewer checked the names were spelled right and approved without a plan, because 'it's just sorting.' Nobody on the team understood that count addresses are positional, so nobody knew the diff would renumber every instance.
Root cause
The fleet used count over a name list, so addresses were positional: web[0], web[1], and so on. Sorting the list reassigned every name to a new index, and Terraform interpreted each reassignment as destroy-plus-create. Nothing about the servers changed — only their positions in a list — but position was identity, so everything looked replaced.
Fix
Recovery took two phases. First, immediate: they restored the original list order, confirmed the plan went empty, and shelved the cleanup — the fleet was never actually harmed because the plan was caught in review. Second, structural: they migrated the fleet to for_each keyed by hostname with moved blocks, verified an empty plan, applied the pure remap, and only then re-applied the alphabetical sorting — which produced no changes at all.
Key lesson
  • Variable-file edits are infrastructure changes when count is involved — require plans on every pull request that touches lists backing count, no matter how cosmetic.
  • Catch destroy plans in review, not in apply: the team's rule that any destroy in a plan needs explicit human approval is what saved the fleet.
  • Sort-proof your configs proactively: any count-backed list a human might reorder is a migration candidate, not a stable design.
Production debug guideFive Terraform count/for_each failure patterns, with the exact steps that fix each.5 entries
Symptom · 01
Plan proposes destroys on instances you didn't intend to touch
→
Fix
Don't apply the destroy plan. Convert the resource to for_each keyed by the stable attribute (hostname, name), add a moved block per instance from index to key, and run terraform plan — iterate until the plan is empty, proving pure state remap with zero infrastructure change. Then apply.
Symptom · 02
Removing one list item destroys every instance after it
→
Fix
Convert to for_each keyed by the identity attribute first, with moved blocks covering every current index. After that, removing a key destroys exactly one instance. Verify with terraform plan before applying — the diff should name only the removed key.
Symptom · 03
Migration plan shows destroy-and-create instead of moves
→
Fix
Run terraform plan and confirm the only changes are address remaps (listed as moved, not replaced). If any resource shows destroy/create, its moved block is missing or mistyped — compare each from address against terraform state list output.
Symptom · 04
Downstream resources attach to the wrong instances after a reorder
→
Fix
Replace positional access with keyed lookups: aws_instance.web["a"].id or iterate with for k, inst in aws_instance.web. Run terraform validate then terraform plan to confirm downstream diffs reference the intended instances.
Symptom · 05
Renaming a keyed attribute still replaces the instance
→
Fix
Change the key source to an immutable attribute (username, hostname) with grep -rn 'for_each' --include='*.tf' . to find every keyed resource, and keep mutable settings in the value. Re-plan: pure updates mean the keys are stable.
Terraform count vs for_each Problems — Root Cause Comparison
Root CauseHow to ConfirmFixPrevention
List backing count reordered or shrankPlan shows destroys on surviving instances after a list editMigrate to for_each with stable keys plus moved blocksUse for_each for named things; reserve count for fixed sets
count removed mid-list, shifting later indexesPlan destroys index N+1 onward when item N is deletedKey by stable identity so removal affects one instanceDelete by key, never by position, in reviews
Migration skipped moved blocksPlan shows destroy-all plus create-all on a pure refactorAdd moved blocks from each index to each keyRequire moved blocks plus empty-plan proof on migrations
Downstream indexed into instances positionallyOutputs or attachments follow list position, not identityLook up by key with each.value and keyed referencesForbid positional indexing into for_each resources
⚙ Quick Reference
5 commands from this guide
FileCommand / CodePurpose
count-trap.tfvariable "server_names" {Why Reordering a List Destroys Servers Under count
for-each-stable.tfvariable "servers" {for_each Keys
migration.tfmoved {Migrating Live Fleets With moved Blocks and Zero Downtime
keyed-references.tfresource "aws_eip" "web" {Fixing Downstream References That Still Think Positionally
count-proper.tfresource "aws_nat_gateway" "main" {Count Still Has a Job

Key takeaways

1
Count addresses by position, so reorders and mid-list deletions renumber survivors into destroy-and-recreate plans.
2
for_each addresses by stable key
edits touch only the added, removed, or changed keys, never innocent neighbors.
3
Migrate with one moved block per instance from index to key, and demand an empty plan as proof.
4
Key by immutable identity (hostname, username) and keep changeable settings in the value.
5
Never index for_each resources positionally downstream
look up by key or pass whole objects.
6
Reserve count for fixed interchangeable sets; default everything with identity to for_each.

Common mistakes to avoid

5 patterns
×

Reaching for count by default for every multi-instance resource

Symptom
Every list reorder or removal produces destroy-and-recreate plans on instances that should have been untouched, and the team starts fearing routine config edits.
Fix
Use for_each with stable string keys for any collection that can reorder or lose members. Count is for fixed-size, order-independent sets (like three identical availability-zone workers) — not for named servers.
×

Keying for_each on an attribute that itself changes

Symptom
Renaming a keyed attribute destroys and recreates the instance — you've rebuilt index fragility with string keys, and the plan shows a replace on what should be an update.
Fix
Key by the stable identity (hostname, bucket name, username) and let the value carry the changeable attributes. Keys are identity; values are configuration.
×

Feeding for_each a raw list instead of a map or set

Symptom
Terraform errors that for_each needs a map or set of strings, and beginners wrap the list in tricks that reintroduce ordering — defeating the whole migration.
Fix
Convert with toset() or build the map explicitly: { for u in var.users : u.name => u }. Keep the transformation beside the resource so reviewers can see the key source at a glance.
×

Migrating count to for_each without moved blocks

Symptom
Terraform plans to destroy all indexed instances and create keyed replacements — a full outage disguised as a refactor, with new IPs, new volumes, and lost data.
Fix
Write moved blocks for every address change, plan to confirm zero-change moves, then apply. Keep the blocks for one release cycle so teammates' branches rebase cleanly, then remove them.
×

Indexing into for_each resources positionally like a list

Symptom
Downstream resources attach to the wrong instances after a reorder, because values[0] silently means a different machine than it did last week.
Fix
Reference whole objects (each.value) or look up by key. Splat expressions over for_each resources return values in key order — never assume positional alignment with another collection.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What's the practical difference between count and for_each?
Q02JUNIOR
Why does deleting one item from a count list destroy multiple instances?
Q03SENIOR
How do moved blocks make a count-to-for_each migration safe?
Q04SENIOR
How do you choose for_each keys that won't cause replaces later?
Q05SENIOR
A count-based fleet feeds outputs into three downstream modules. How do ...
Q01 of 05JUNIOR

What's the practical difference between count and for_each?

ANSWER
Count addresses instances by position (web[0], web[1]), so removing or reordering list items renumbers survivors and Terraform replaces them. for_each addresses by stable key (web["a"]), so edits affect only the added, removed, or changed keys. Use count for fixed interchangeable sets, for_each for anything with identity.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Is reordering a count list safe if no elements are added or removed?
02
Should all new multi-instance resources use for_each?
03
Does a moved block change any infrastructure?
04
How long should moved blocks stay in the config?
05
My input is a plain list. Can for_each use it directly?
06
Can I change an instance's settings without recreating it under for_each?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Everything here is grounded in real deployments.

Follow
✓ Verified
production tested
September 27, 2026
last updated
2,085
articles · all by Naren
🔥

That's Terraform. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Terraform Provider Configuration Not Present After Refactor
5 / 5 · Terraform
Next
Azure AADSTS700016: Application Not Found in Directory
→