skip to content

In Terraform, what is the practical difference between the count and for_each meta-arguments, and why can removing one element from the middle of a list used with count destroy resources you never meant to touch?

level: middleimportance: must knowfreq 78%

answer

  1. one block, many instances
  2. look at the resource address
  3. position is not identity
  4. index shifts, key does not
  5. middle deletion, cascading replacement

basics

~20 s

count addresses instances by numeric position, so deleting a middle list element shifts every later index and Terraform destroys and recreates those resources. for_each addresses instances by map key or set value, which stays stable, so unrelated instances are untouched.

solid answer

~50 s

Both meta-arguments turn one resource block into many instances, but they differ in how each instance is addressed in state. `count` produces index addresses like `aws_iam_user.team[0]`, `[1]`, `[2]`, and the index comes purely from position in the list. `for_each` takes a map or a set of strings and produces key addresses like `aws_iam_user.team["ana"]`. If I delete `"ben"` from the middle of a three-element list under `count`, index 1 now holds what used to sit at index 2 and index 2 disappears, so Terraform plans a changed `name` at `[1]` — usually a forced replacement — plus a destroy at `[2]`. Two resources churn when one should have been removed. With `for_each` the key `"ben"` simply goes away and the other keys are untouched. The working rule: `for_each` whenever the instances have a meaningful identity, `count` only for interchangeable copies or the 0/1 conditional.

code

hcl · 16 lines
hcl
variable "user_names" {
  type    = list(string)
  default = ["ana", "ben", "cara"]
}

# Index-addressed: aws_iam_user.by_count[0], [1], [2]
resource "aws_iam_user" "by_count" {
  count = length(var.user_names)
  name  = var.user_names[count.index]
}

# Key-addressed: aws_iam_user.by_key["ana"], ["ben"], ["cara"]
resource "aws_iam_user" "by_key" {
  for_each = toset(var.user_names)
  name     = each.value
}

go deeper

for a junior

Know that both meta-arguments create several copies of one resource block, and that count gives you count.index while for_each gives you each.key and each.value. Be able to write both forms.

for a middle

Explain the addressing difference — index versus key — and walk an interviewer through what the plan does when an element is removed from the middle of a counted list. Mention that for_each needs a map or a set of strings.

for a senior

Demonstrate judgment on an existing repository: spot the counted resources that carry identity, read a plan for unintended replacements, and describe migrating to for_each safely rather than merging the change and hoping.

for a principal

Own this as a convention, not a preference. Argue for a house rule plus a policy check that flags count over a variable-length list, and weigh the one-time migration cost against the estate-wide risk of an index-shift outage.

## Two meta-arguments for the same job A Terraform `resource` block normally declares one real object. `count` and `for_each` are *meta-arguments* — arguments Terraform interprets itself rather than passing to the provider — that expand a single block into multiple **instances**. A block may use one or the other, never both; combining them is a configuration error. ```hcl resource "aws_iam_user" "team" { count = 3 name = "user-${count.index}" } ``` `count` takes a whole number. Inside the block, `count.index` is the zero-based position of the instance being built. ```hcl resource "aws_iam_user" "team" { for_each = toset(["ana", "ben", "cara"]) name = each.value } ``` `for_each` takes a **map** or a **set of strings** — a list is rejected, which is why `toset(...)` appears so often. Inside the block, `each.key` is the map key (or, for a set, the element itself) and `each.value` is the map value (again the element itself for a set). ## Addressing is the whole story The difference that matters is not syntax, it is the **resource address** Terraform records in state for each instance: - `count` → `aws_iam_user.team[0]`, `aws_iam_user.team[1]`, `aws_iam_user.team[2]` - `for_each` → `aws_iam_user.team["ana"]`, `aws_iam_user.team["ben"]`, `aws_iam_user.team["cara"]` State is a mapping from address to a real remote object. On every plan Terraform compares the set of addresses your configuration *would* produce against the set of addresses already in state. Addresses that appear only in configuration are creations; addresses only in state are destroys; addresses in both are compared attribute by attribute. Nothing in that algorithm knows that index `1` "means" Ben. The index is derived from position in the input list, and position is not identity. ## The re-indexing accident Start with `var.names = ["ana", "ben", "cara"]` and `count = length(var.names)`, `name = var.names[count.index]`. State holds three addresses. Now someone removes `"ben"` in a pull request that looks like a one-line deletion. The list becomes `["ana", "cara"]`, so: - `[0]` still wants `name = "ana"` → no change. - `[1]` previously held Ben, now wants `name = "cara"`. Terraform does not rename it; `name` on `aws_iam_user` forces a new resource, so this is **destroy and recreate**. - `[2]` exists in state but is no longer produced by the configuration → **destroy**. The reviewer expected one destroy. The plan does two destroys and one create, and the object that survives untouched is not the one anyone predicted. Substitute EC2 instances, disks, or DNS records for IAM users and this is an outage. The same happens on an **insertion** anywhere but the end, and on a **reorder** — sorting the list alphabetically can churn the entire set. With `for_each = toset(var.names)`, removing `"ben"` removes exactly the address `["ben"]`. `["ana"]` and `["cara"]` are still produced by the configuration, still match state, and generate no diff at all. ## What each one requires Both values must be **known at plan time** — Terraform has to compute how many instances exist before it can render a diff. For `for_each` the requirement is specifically on the *keys*; the values in the map may be unknown until apply. `for_each` also demands that keys be strings, and that they be unique — that is precisely what buys stability. When your source data is a list of objects, project it into a map with a `for` expression keyed by a stable field: ```hcl for_each = { for u in var.users : u.name => u } ``` Pick the key from something that will not change when unrelated data changes. Keying by an ordinal (`index`) recreates the count problem with extra steps. ## When count is still the right answer `count` is not deprecated and has two honest uses: 1. **Conditional creation** — `count = var.enabled ? 1 : 0`, the standard idiom for "this resource exists only sometimes". 2. **Genuinely interchangeable copies** — N identical workers where nothing distinguishes instance 3 from instance 4 and the number only ever changes at the end. Even here, scaling down still removes the highest indices, which may not be the ones you want gone. Everything else — one resource per environment, per subnet, per team, per region — has identity, and identity wants `for_each`. ## Fixing an existing repository Switching a live `count` resource to `for_each` naively produces a plan that destroys every instance and creates them again under new addresses, because every address changed. Terraform's `moved` block (available since Terraform 1.1) lets you declare the old address and the new one so the plan becomes a no-op state rename instead of a rebuild. That migration is worth doing before the list is edited under pressure, not after. ## The interview summary `count` indexes by position, `for_each` keys by identity; positions shift and identities do not. Reach for `for_each` by default, keep `count` for on/off and for anonymous replicas, and read any plan touching a counted resource for destroys you did not ask for.

  • Can a single resource block use both count and for_each?
    No. They are mutually exclusive and Terraform rejects the configuration. If you need both behaviours — say, an optional set of resources — express the condition in the collection itself: build an empty map when the feature is off, for example `for_each = var.enabled ? var.subnets : {}`, so zero instances are produced.
  • Your input is a list of objects, not a map or a set of strings. How do you feed it to for_each?
    Project it with a `for` expression that picks a stable field as the key: `for_each = { for u in var.users : u.name => u }`. Then `each.key` is the name and `each.value` is the whole object. Never key by position — that reintroduces exactly the index fragility for_each exists to remove.
  • Is there any case where count is genuinely the better choice for many instances?
    Yes, when the instances are interchangeable and unlabelled — N identical workers behind a load balancer, sized by a number — and when the count only grows or shrinks at the tail. There is no meaningful key to give them, and inventing one adds noise. Anything with a name, a region, or an environment attached should use for_each.

saying these in an interview costs you the question

  • Says for_each is just newer syntax for count
  • Claims Terraform tracks instances by name, so reordering is safe
  • Thinks changing count only adds or removes at the end
  • Believes for_each accepts a plain list
  • Expects Terraform to rename instances automatically on apply

context