Skip to main content
DevOps Insightsguides & answers

Terraform · commonly confused

count vs for_each

Short answer

count names each instance by its position in a list; for_each names it by a key. Remove an item from the front or middle of a list driven by count and every instance after it shifts down one position, so Terraform rewrites or replaces resources you never touched. for_each removes only the one you removed. And switching an existing count resource to for_each without moved blocks destroys and recreates every instance.

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.tfstate unless 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 from and to addresses. 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_replace value 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-in terraform_data resource, and moved blocks 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_data in the configuration, terraform init downloads no provider, no cloud account or credentials are involved, and state is written to terraform.tfstate in that folder.
  • For a real resource, the list of its current instance addresses from terraform state list. You need one moved block per existing index, so write them down before you change anything.
  • Permission to run terraform plan against 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 with terraform plan -out=tfplan and 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 list shows 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] held alpha and now gets bravo, and [1] goes from bravo to charlie. Because triggers_replace forces replacement, both must be replaced, and [2] is destroyed as out of range for count. Removing one item touched all three objects.
  • Under for_each, only ["alpha"] appears, because its key is not in for_each map. bravo and charlie are 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_count itself to for_each planned 3 to add, 0 to change, 3 to destroy, each old index destroyed because resource does not use count. Adding one moved block per index turned that into 0 to add, 0 to change, 0 to destroy, with each line reading has 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 argument with the "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 as set(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 from index 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 says has moved to and the plan reads 0 to add, 0 to change, 0 to destroy.
Error: Instance cannot be destroyed with has lifecycle.prevent_destroy set, but the plan calls for this resource to be destroyed.
prevent_destroy caught a count shift. Do not switch it off or use -target to 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_each and left count in the same block, often halfway through a conversion. Remove count, change count.index references to each.key or each.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 as index_key in 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 with Instance cannot be destroyed instead 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 from line, then apply the plan you reviewed with terraform 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_destroy both suggest -target. A targeted apply skips the rest of the configuration, and HashiCorp warns it can lead to undetected drift; using it to get past prevent_destroy removes the guard you added.

In an interview

How this gets asked in an interview

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.

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