Terraform · commonly confused
count vs for_each
Short answer
Reproduced on Terraform 1.14, . Where the text says “reproduced”, the behaviour was run on that version, not taken from documentation.
First published
On this page · 10 sections
In simple terms
Both make several copies of one resource from a single block. The difference is how Terraform tells the copies apart. With count, the copies are numbered: the first is [0], the second [1], and so on. With for_each, each copy is named by a key you choose, such as ["alpha"] or ["bravo"]. Terraform remembers every real object by that number or that name, and the trouble with numbers is that they move.
Picture three servers made with count from the list alpha, bravo, charlie. Delete alpha from the list and the remaining two do not keep their numbers — bravo becomes [0] and charlie becomes [1]. Terraform now believes [0] should be bravo instead of alpha, [1] should be charlie instead of bravo, and [2] should not exist. Three objects change to remove one. If the attribute that moved is one that cannot be edited in place, such as an S3 bucket's name, each of those changes is a destroy and recreate.
With for_each, the same deletion is exactly what it looks like. The object named alpha goes; bravo and charlie keep their names, so Terraform leaves them alone. That stability is a good reason to prefer for_each whenever the copies are not genuinely interchangeable.
The catch is the migration. Changing an existing resource from count to for_each changes every instance's name in Terraform's records, from [0] to ["alpha"] and so on. Terraform does not guess that they are the same objects — it plans to destroy all the old ones and create new ones. Moved blocks are how you tell it they are the same, and with them the switch changes nothing in your infrastructure.
Words used on this page
- instance
- One of the copies that count or for_each makes from a single block. Each instance has its own real object.
- instance address
- The name Terraform records an instance under, such as terraform_data.by_count[0] or terraform_data.by_each["alpha"]. A plan matches configuration to state address by address.
- count.index
- The position of a count instance, starting at 0. It is the part in square brackets in the address, and it shifts when an earlier list item is removed.
- each.key
- The map key or set member that names a for_each instance. It stays the same when other items are added or removed.
- set
- A collection of unique values with no order.
toset()turns a list into one, dropping duplicates, which is the form for_each accepts. - state
- Terraform's record of which real object belongs to which address, kept in
terraform.tfstateunless you configure a remote backend. - plan
- Terraform's list of proposed actions. Nothing changes until you apply it.
- replace
- Destroy an object and create a new one, shown as
-/+in a plan. By default Terraform destroys the old object first. - moved block
- A configuration block with
fromandtoaddresses. It tells Terraform to rename an existing object in state instead of destroying it and creating a new one. - terraform_data
- A resource built into Terraform that only stores values in state, so you can try plans without a cloud account. A change to its
triggers_replacevalue forces replacement.
What actually happens
Terraform tracks every real object in state under an instance address. With count the address carries an integer index, resource[0]; with for_each it carries a map key or set element, resource["key"]. A plan is a comparison between configuration and state at each address. When a list driven by count shrinks from the front, the values at each surviving index change, so Terraform plans an update or a replacement at every shifted address and a destroy at the last one. It is not deleting the item you removed; it is reconciling each position.
Reproduced on Terraform 1.14 with three terraform_data instances named alpha, bravo and charlie, whose replace-forcing attribute was set from the list. Removing alpha under count planned: by_count[0] must be replaced, by_count[1] must be replaced, by_count[2] will be destroyed. The identical change under for_each planned only: by_each["alpha"] will be destroyed. Whether shifted instances are updated in place or replaced depends on whether the attribute that changed can be modified in place — the shift happens either way.
HashiCorp's guidance draws the line in the same place: use count when you want to create nearly identical instances, and for_each when instances need distinct values that cannot be derived from an integer index. You cannot use both in the same block. And the count value must be known before Terraform performs any remote operations, so it cannot depend on an attribute that only exists after apply.
Converting an existing resource from count to for_each changes every instance address, which Terraform reads as removing the old objects and creating new ones — reproduced as 3 to add and 3 to destroy for three instances. A moved block declares that an object at one address is the same object at another: the documentation describes it as changing a resource's address, renaming the existing object in state before planning. With one moved block per instance, the same conversion planned 0 to add, 0 to change and 0 to destroy.
Moving from count to for_each without destroying anything
# Before: addressed by position.
resource "aws_s3_bucket" "logs" {
count = length(var.envs) # var.envs = ["dev", "staging", "prod"]
bucket = "<prefix>-logs-${var.envs[count.index]}"
}
# After: addressed by key. On its own this change plans to destroy all three
# buckets and create three new ones — the addresses all changed.
resource "aws_s3_bucket" "logs" {
for_each = toset(var.envs)
bucket = "<prefix>-logs-${each.key}"
}
# Tell Terraform the objects are the same. Each block renames one instance in
# state; with all three, the plan is 0 to add, 0 to change, 0 to destroy.
moved {
from = aws_s3_bucket.logs[0]
to = aws_s3_bucket.logs["dev"]
}
moved {
from = aws_s3_bucket.logs[1]
to = aws_s3_bucket.logs["staging"]
}
moved {
from = aws_s3_bucket.logs[2]
to = aws_s3_bucket.logs["prod"]
}The bucket prefix is a placeholder. Check that each index maps to the key that holds the same values — moved blocks rename objects; they do not compare them. Run terraform plan and confirm it reports only moves before applying.
Try it yourself
Before you start
- Terraform installed (
terraform version). The worked example needs 1.4 or later for the built-interraform_dataresource, andmovedblocks need 1.1 or later. Everything on this page was reproduced on Terraform 1.14.0. - An empty folder to experiment in. With only
terraform_datain the configuration,terraform initdownloads no provider, no cloud account or credentials are involved, and state is written toterraform.tfstatein that folder. - For a real resource, the list of its current instance addresses from
terraform state list. You need onemovedblock per existing index, so write them down before you change anything. - Permission to run
terraform planagainst the workspace's state, and the habit of reading every per-address line of a plan before you apply it. For real changes, save the plan you reviewed withterraform plan -out=tfplanand apply that file. - For stateful resources such as databases, buckets and volumes, a current backup or snapshot before you remove list items or convert to for_each.
Removing the first item from a list under count and under for_each
Reproduced on Terraform 1.14.0 on macOS in an empty folder, with the time zone set to UTC. The configuration, printed below, uses only the built-in terraform_data resource: terraform init reported terraform.io/builtin/terraform is built in to Terraform, downloaded nothing and wrote no lock file. terraform apply -auto-approve then created all six instances from the default list. The plan removes alpha from the list and is filtered to the address and attribute lines. Keep -no-color: without it Terraform kept its colour codes when piped, and grep missed the address lines.
Command
cat main.tf terraform state list terraform plan -no-color -var='names=["bravo","charlie"]' | grep -E '^ #|triggers_replace|^Plan:'
Output
$ cat main.tf
variable "names" {
type = list(string)
default = ["alpha", "bravo", "charlie"]
}
resource "terraform_data" "by_count" {
count = length(var.names)
triggers_replace = var.names[count.index]
}
resource "terraform_data" "by_each" {
for_each = toset(var.names)
triggers_replace = each.key
}
$ terraform state list
terraform_data.by_count[0]
terraform_data.by_count[1]
terraform_data.by_count[2]
terraform_data.by_each["alpha"]
terraform_data.by_each["bravo"]
terraform_data.by_each["charlie"]
$ terraform plan -no-color -var='names=["bravo","charlie"]' | grep -E '^ #|triggers_replace|^Plan:'
# terraform_data.by_count[0] must be replaced
~ triggers_replace = "alpha" -> "bravo"
# terraform_data.by_count[1] must be replaced
~ triggers_replace = "bravo" -> "charlie"
# terraform_data.by_count[2] will be destroyed
# (because index [2] is out of range for count)
- triggers_replace = "charlie" -> null
# terraform_data.by_each["alpha"] will be destroyed
# (because key ["alpha"] is not in for_each map)
- triggers_replace = "alpha" -> null
Plan: 2 to add, 0 to change, 4 to destroy.How to read it
terraform state listshows how each copy is recorded. The count copies are numbered[0]to[2]; the for_each copies are named["alpha"],["bravo"]and["charlie"]. The plan compares configuration with these records one address at a time.- Under count,
[0]heldalphaand now getsbravo, and[1]goes frombravotocharlie. Becausetriggers_replaceforces replacement, bothmust be replaced, and[2]is destroyed asout of range for count. Removing one item touched all three objects. - Under for_each, only
["alpha"]appears, because its keyis not in for_each map.bravoandcharlieare missing from the plan because nothing about them changed. Plan: 2 to add, 0 to change, 4 to destroy.covers both resources: count's two replacements and one destroy, plus for_each's one destroy. Read the per-address lines, not just the totals.- In the same folder, changing
by_countitself to for_each planned3 to add, 0 to change, 3 to destroy, each old index destroyedbecause resource does not use count. Adding onemovedblock per index turned that into0 to add, 0 to change, 0 to destroy, with each line readinghas moved to.
The mistake this causes
Switching an existing resource from count to for_each, the fix for count's index problem, without adding moved blocks.
Why people do it The change looks like a refactor that should do nothing, and the count and for_each reference pages do not mention the migration. But every instance address changes from an index to a key, and Terraform treats a new address with no state behind it as a new object and the old address as one to destroy.
What you see terraform plan reports every existing instance to be destroyed and the same number to be created. Applied to stateful resources — buckets, databases, volumes — that can mean data loss, triggered by a change intended to make the configuration safer.
If it goes wrong
Error: Invalid for_each argumentwiththe "for_each" argument must be a map, or set of strings, and you have provided a value of type list of string.- for_each does not take a list. Wrap it in
toset(), or declare the variable asset(string). A set drops duplicates and has no order, so if you need several values per item, build a map instead and use its keys. The "for_each" set includes values derived from resource attributes that cannot be determined until apply, or for count,The "count" value depends on resource attributes that cannot be determined until apply- The keys or the number come from something that only exists after apply, such as a generated ID. Derive them from values in the configuration, such as variables or fixed names, and put apply-time values in the map's values, not its keys. The message also offers
-target; HashiCorp's plan documentation keeps that for exceptional circumstances. - After adding moved blocks, the plan still shows
must be replaced, with a line such as(moved from terraform_data.by_count[1]) - A
fromindex is paired with the wrong key. Terraform moved the object as told and then found its settings differ from the new key's configuration. Fix the pairs until every line sayshas moved toand the plan reads0 to add, 0 to change, 0 to destroy. Error: Instance cannot be destroyedwithhas lifecycle.prevent_destroy set, but the plan calls for this resource to be destroyed.prevent_destroycaught a count shift. Do not switch it off or use-targetto get past it. Convert the resource to for_each with moved blocks first, confirm a plan with only moves, then remove the item.Error: Invalid combination of "count" and "for_each"- You added
for_eachand leftcountin the same block, often halfway through a conversion. Removecount, changecount.indexreferences toeach.keyoreach.value, and add the moved blocks.
How it shows up in production
A module creates one storage bucket per environment from a list, using count. Someone removes a retired environment from the front of the list, and the plan proposes replacing the buckets for every environment after it — because each has slid down one index and its name changed with it.
Plan review catches it, and the team does the right thing in principle: switch the resource to for_each, so environments are addressed by name and removing one touches only that one.
The next plan proposes destroying all of the remaining buckets and creating new ones. The switch changed every address from [n] to ["env"], and nothing told Terraform the objects were the same. The fix for the first near-miss nearly causes a larger one.
Adding a moved block for each existing instance turns that plan into pure renames — no destroys, no creates — after which removing an environment destroys only that environment's bucket.
How to tell which one you are hitting
- Would removing an item from the middle of the list be safe?
- With count, every later instance changes or is replaced. If that is unacceptable, as it can be for anything holding data, use for_each.
- Are the copies genuinely interchangeable, differing only by number?
- That is the case HashiCorp's guidance names for count: nearly identical instances. Five identical workers is a fair use. Anything with a meaningful name per copy is for_each.
- Is the number or set of keys only known after apply?
- Neither can depend on values that do not exist until apply — count must be known before remote operations begin — so restructure to derive them from known inputs.
- Are you converting a resource that already exists?
- Then write a moved block for every existing instance first, and confirm terraform plan shows only moves before applying anything.
Security considerations
- for_each keys are printed. Each key becomes part of a resource address, shown in every plan, CI log and
terraform state list, and stored asindex_keyin state. Terraform refuses a variable marked sensitive (reproduced:Sensitive values, or values derived from sensitive values, cannot be used as for_each arguments.), but it cannot tell that a plain variable holds a secret. Key by names you are happy to see in logs, never by tokens or passwords. - A count shift replaces objects destroy-first by default (
-/+ destroy and then create replacement). If count drives access rules such as firewall rules or policy attachments, removing an early item destroys and recreates the rules after it, so for part of the apply they are not in force, and longer if the apply fails partway. - Put
lifecycle { prevent_destroy = true }on stateful resources made with count or for_each. Reproduced, a count shift then fails at plan time withInstance cannot be destroyedinstead of planning replacements. It does not cover deleting the resource block itself: Terraform still destroys the object then. - Moved blocks rename without comparing. A swapped pair could attach production's object to a dev key and rewrite it to dev's settings. Review every
moved fromline, then apply the plan you reviewed withterraform apply tfplan. Treat that file as sensitive: Terraform's documentation says it holds sensitive values in cleartext, even ones hidden in the terminal. - The errors for unknown counts and for
prevent_destroyboth suggest-target. A targeted apply skips the rest of the configuration, and HashiCorp warns it can lead to undetected drift; using it to get pastprevent_destroyremoves the guard you added.
In an interview
How this gets asked in an interviewpreparation
Usually phrased as When would you use for_each instead of count in Terraform?
What a strong answer contains When instances have identities of their own rather than positions. count addresses instances by index, so removing an item before the end shifts every later instance and changes or replaces it; for_each addresses by key, so removing one removes only that one. count fits nearly identical, interchangeable copies. The answer that shows experience names the migration trap: switching an existing resource from count to for_each changes every address, so without moved blocks Terraform plans to destroy and recreate everything.
The follow-up "You switched a resource to for_each and the plan wants to destroy every instance. What happened, and how do you fix it without downtime?" The addresses changed from indexes to keys. Add a moved block mapping each old index to its new key, and confirm the plan shows only moves.
Sources
The behaviour this page describes, as documented by the projects themselves.
- Terraform — moved block reference
- Terraform — terraform_data resource
- Terraform — lifecycle meta-argument
- Terraform — terraform plan command
- Amazon S3 — General purpose bucket naming rules
- Amazon S3 API — DeleteBucket
- Terraform — count meta-argument
- Terraform — for_each meta-argument
- Terraform — Refactor modules
Did this get you to an answer?
No text box on purpose — please do not paste production logs anywhere
Something wrong or out of date? Report it