Renaming a Key, Losing a Resource: What a for_each Address Really Is
You have six buckets, one per region, created with for_each over a map. A teammate renames the key "eu-west" to "eu-west-1" so it matches the region's real name and runs plan. The output shows aws_s3_bucket.sites["eu-west"] destroyed and aws_s3_bucket.sites["eu-west-1"] created — new bucket, new ARN, empty content, DNS records that pointed at the old ARN now lying. Nothing about the bucket's arguments changed except a suffix derived from the key. Terraform still replaces it, because with for_each the key is not a label; it is the instance's identity.
variable "sites" {
type = map(object({
region = string
is_production = bool
}))
default = {
"eu-west" = { region = "eu-west-1", is_production = true }
"us-east" = { region = "us-east-1", is_production = false }
}
}
resource "aws_s3_bucket" "sites" {
for_each = var.sites
bucket = "acme-${each.key}"
tags = {
Region = each.value.region
Production = each.value.is_production ? "yes" : "no"
}
}
Each instance is addressed as aws_s3_bucket.sites["eu-west"]. Terraform records state keyed by that address and matches planned instances to existing objects by comparing addresses. Change the string and the match fails on both sides: the old address has no planned instance, so its object is destroyed; the new address has no state object, so it is created.
That property is what separates for_each from the count Meta-Argument, and it is the most asked Terraform interview question in this phase:
| Property | count |
for_each |
|---|---|---|
| Instance address | resource[0] — numeric index |
resource["eu-west"] — string key |
| Removing the first item | renumbers every neighbor → replacement cascade | no other address changes |
| Identity tied to | position in the list | the key itself |
| Reference you write | aws_instance.web[0].id |
aws_instance.web["eu-west"].id |
With count, deleting the first element of a three-element list shifts the remaining ones from indexes 1 and 2 down to 0 and 1. Terraform sees three replacements for a one-item removal. With for_each, adding "ap-south" to the map creates exactly one bucket, and nothing else in the plan moves. The only way to force a replacement is to rename a key — which makes renames deliberate, auditable events rather than accidents.
Stable keys are the design rule. Treat each key like a database primary key: choose it once from something that never changes, and derive mutable attributes from each.value. In the example above, the bucket name derives from each.key, baking the stability into naming. The safer variant keys on an immutable identifier and keeps the human-friendly name as data:
locals {
sites = {
"site-001" = { name = "eu-west", region = "eu-west-1" }
"site-002" = { name = "us-east", region = "us-east-1" }
}
}
Interviewers love this scenario: they describe a rename that replaced a production resource and ask what you would change. The answer has two parts — the key scheme (key by immutable ID, keep the pretty name in the value) and the recovery tooling (moved blocks or terraform state mv, covered at the end of this topic).
Maps also free you from list ordering. A map has no concept of "first" or "reorder"; two plans over the same keys produce the same instance set regardless of how the source list was sorted before conversion. That determinism is the real reason to prefer for_each for anything longer-lived than a scratch environment.
Sets, Not Lists: toset(), keys(), and the Known-at-Plan-Time Rule
for_each accepts exactly two types: a map or a set of strings. Hand it a list and Terraform refuses outright:
Error: Invalid for_each argument
The "for_each" value must not be a list. If you want to create
one resource for each element, use count instead.
The conversion is toset(), and it changes semantics more than syntax. A set has no order and no duplicates, so toset(["a", "b", "a"]) yields exactly { "a", "b" }. That is usually what you want: the elements identify things, not their sequence.
variable "private_subnet_ids" {
type = list(string)
default = ["subnet-0aa1", "subnet-0bb2"]
}
data "aws_subnet" "selected" {
for_each = toset(var.private_subnet_ids)
id = each.value
}
output "subnet_cidrs" {
value = { for s in data.aws_subnet.selected : s.id => s.cidr_block }
}
Here each.value is the subnet ID itself — sets have no keys, so the element doubles as both. When you need metadata alongside the identifier, a map is the better shape: key by the stable name, carry the rest as attributes.
Maps also get derived through built-ins. keys() returns a sorted list of keys, values() the collection of values, and a for expression filters:
locals {
prod_sites = { for name, site in var.sites : name => site if site.is_production }
prod_names = keys(local.prod_sites) # ["eu-west"]
region_list = [for site in values(var.sites) : site.region]
}
The full power of that for filtering shows up in for Expressions and Comprehensions; here it just needs to be familiar as the way raw lists become keyable maps.
Now the rule that bites everyone once: the for_each argument must be fully known at plan time. Plan builds the instance set before any cloud API runs, so every key must already exist. This fails:
# FAILS: aws_subnet.private[*].id is unknown until apply
data "aws_subnet" "selected" {
for_each = toset(aws_subnet.private[*].id)
id = each.value
}
Error: Invalid for_each argument
The "for_each" value depends on resource attributes that cannot
be determined until apply, so Terraform cannot predict how many
instances it will create.
count has the same weakness for the same reason, which is why "just switch to count" is never the fix. Three workarounds, in order of preference:
- Take the values as input. Make the IDs a variable supplied by the caller; variables are known at plan because someone typed them.
- Key on something known.
for_each = { for i, s in var.subnets : "subnet-${i}" => s }— the keys are computed from known structure even when values are rich. - Restructure the module boundary. Let the parent module own the list and pass one entry per module instance; the parent's keys are known even if each child's attributes are not.
A data source deserves extra suspicion here because it reads the cloud at plan time — its own attributes become available only after the read completes. When a for_each over data-source attributes errors during CI but not on your laptop, the difference is usually stale state or an output the pipeline wires in differently, not a Terraform bug.
Two smaller details round out the set story. First, sets are their own iteration universe: inside a toset-driven resource, each.key and each.value are the same string, so read each.value and ignore each.key — reviewers use that to tell a set loop from a map loop at a glance. Second, toset only accepts lists of strings; toset(list(object(...))) is an error because set elements must be hashable strings. When you have rich objects, build a map keyed by a stable field instead:
locals {
subnets_by_name = { for s in var.subnets : s.name => s }
}
data "aws_subnet" "by_name" {
for_each = local.subnets_by_name
id = each.value.id
}
Here the data source reads IDs that were supplied in configuration, so every key is known during planning — the same lookup written over aws_subnet.private[*].id would not be.
Referencing Instances by Key and Renaming Without a Destroy
Creating instances is half the job; referencing them is where for_each syntax shows up daily. The address carries the key in brackets, quoted:
# one specific instance
output "eu_bucket_arn" {
value = aws_s3_bucket.sites["eu-west"].arn
}
# module instances work the same way
output "module_bucket_arn" {
value = module.sites["eu-west"].bucket_arn
}
# turn the whole map into a list when an argument needs one
resource "aws_route53_record" "site_aliases" {
for_each = local.prod_sites
name = "${each.key}.example.com"
type = "A"
zone_id = var.zone_id
alias {
name = aws_cloudfront_distribution.sites[each.key].domain_name
zone_id = aws_cloudfront_distribution.sites[each.key].hosted_zone_id
evaluate_target_health = false
}
}
Note what does not exist: count.index, and no positional indexing at all. When a downstream argument genuinely wants a list — an IAM policy's Resource list, for instance — the bridge is values():
locals {
all_bucket_arns = values(aws_s3_bucket.sites)[*].arn
}
values() converts the map to a list of objects, and the splat expression pulls the attribute. You will use that pairing constantly, and Splat and Attribute Access Expressions treats it properly.
Renaming a key without destroying anything is a state operation, and Terraform gives you two equivalent tools. The declarative one is a moved block, available since Terraform 1.1:
moved {
from = aws_s3_bucket.sites["eu-west"]
to = aws_s3_bucket.sites["eu-west-1"]
}
Plan now shows no destruction — Terraform rewrites the state address during refresh and the existing bucket becomes the new instance. The imperative alternative is the same move performed by hand:
terraform state mv 'aws_s3_bucket.sites["eu-west"]' 'aws_s3_bucket.sites["eu-west-1"]'
Prefer the moved block in teams: it lives in version control, every reviewer sees the rename, and it runs automatically in CI instead of depending on someone's terminal history. The state mv form remains useful for one-off repairs after an incident.
Two caveats interviewers may push on. First, moved blocks are for renames, not for changing how instances are computed — moving from count to for_each wholesale needs one block per instance, which is exactly the refactoring workflow moved was built for. Second, after a key rename, any attribute derived from the key (like acme-${each.key} above) genuinely changes, so plan will still show an in-place update of that attribute — no destroy, but not a pure no-op either. Deleting a key entirely has no such ceremony: the instance's object is destroyed on the next apply, which is correct and worth confirming in plan before you approve it.
The habit to leave with: read the plan's instance addresses, not just its resource types. ~ resource["new-key"] and - resource["old-key"] side by side almost always mean a key was renamed and no moved block was written.
When the refactor is bigger than a rename — a wholesale migration from count to for_each — the same tool scales, one block per instance:
moved {
from = aws_s3_bucket.sites[0]
to = aws_s3_bucket.sites["eu-west"]
}
Terraform accepts mixed old and new addressing in moved, and CI replays every one of them automatically. The reverse operation exists too: since Terraform 1.7, a removed block retires an instance from state without destroying the object, which is how you decommission a for_each entry while keeping the cloud resource — lifecycle { destroy = false } inside it makes the intent explicit. Between moved for renames and removed for retirements, every key-lifecycle event has a declarative, reviewable representation instead of a terminal command someone has to remember.
A last review habit: after any key-scheme change, read the plan summary line by line. Plan: 2 to import, 0 to change, 0 to destroy tells you the moves registered; Plan: 1 to destroy, 1 to create tells you a moved block is missing — before anything reaches production.
Quiz
1. A map keyed by region name gains a new entry and loses its first entry in the same commit. What does the resulting plan look like with for_each?
2. You write `for_each = toset(aws_subnet.private[*].id)` on a data source and plan fails with 'depends on resource attributes that cannot be determined until apply'. What is the root cause?
3. You must rename `aws_s3_bucket.sites["eu-west"]` to `sites["eu-west-1"]` so the bucket is preserved. What is the version-controllable way to do it?
4. A for_each resource's keys are stable, but a downstream IAM policy needs the ARNs of every instance as a list. Which expression is correct?
Flashcards
Question
What is the instance address of a for_each resource, and how does Terraform use it?
Click to reveal answer
Answer
The address is the resource's fully qualified name with the key in brackets, such as aws_s3_bucket.sites["eu-west"]. Terraform stores state keyed by that address and matches planned instances to existing objects by comparing addresses — the key is the identity, not a label, so changing the key means destroying the old instance and creating a new one.
Question
Why does for_each refuse a list, and what are the two accepted types?
Click to reveal answer
Answer
It accepts a map or a set of strings only. Lists are positional, and positions are not identities — removing an element would renumber neighbors. Convert with toset() for a set (deduplicates, ignores order) or build a map keyed by a stable name.
Question
When does for_each fail with 'cannot be determined until apply', and what are two workarounds?
Click to reveal answer
Answer
When the collection's keys come from attributes that are unknown at plan time (for example, IDs of resources not yet created). Workarounds: pass the values as input variables, or key on something known such as a derived constant or index — switching to count does not help because it needs a known length.
Question
How do you reference one instance of a for_each resource, and how do you get a list of an attribute across all instances?
Click to reveal answer
Answer
One instance: aws_s3_bucket.sites["eu-west"].arn (quote the key). A list of every instance: values(aws_s3_bucket.sites)[*].arn — values() turns the instance map into a list, then the splat extracts the attribute.
Question
How do you rename a for_each key without destroying the underlying object, and why prefer that over state mv?
Click to reveal answer
Answer
Declare a moved block from the old address to the new one (moved { from = ... to = ... }). It lives in version control, gets reviewed, and runs in every CI plan automatically. terraform state mv does the same thing but only on one machine at one moment, leaving teammates and pipelines unaware.
Revision Notes
Key Takeaways
- 1. A for_each instance address embeds its key; Terraform matches state by address, so a renamed key is a destroy plus a create unless you declare a moved block
- 2. for_each takes maps or sets of strings — toset() converts lists and silently deduplicates, which is usually the behavior you want
- 3. Every key must be known at plan time; values computed by other resources break for_each exactly as they break count
- 4. Reference instances with bracketed keys (module.sites["eu-west"].id) and convert to lists with values(...)[*].attr
- 5. moved blocks beat terraform state mv for renames because they live in version control and run in every pipeline
Interview Tips
- • "Why for_each instead of count?" — answer with addressing, not syntax: keys are stable identity, removals never renumber neighbors, so a one-item change produces a one-item plan
- • "What happens when you rename a for_each key?" — destroy and create, because state matches by address; then immediately describe the moved block that avoids it — that two-step answer is what interviewers are listening for
- • "Why does for_each fail on a computed list?" — plan must enumerate the instance set before any API call; name the fix (known keys, input variables) and note count fails identically
- • "How do you get a list of attributes from a for_each resource?" — values(map)[*].attr; showing you know splat does not apply to maps directly earns the point
Cheat Sheet
for_each over Maps and Sets
Addressing:
| Meta-argument | Instance address | Removing the first item |
|---|---|---|
count |
res[0] (numeric) |
renumbers neighbors → replace cascade |
for_each |
res["key"] (string) |
only that instance is destroyed |
Accepted types: map, or set of strings (toset() converts a list; dedupes, order-free).
resource "aws_s3_bucket" "sites" {
for_each = var.sites # map(object(...)) — keys are identity
bucket = "acme-${each.key}"
}
output "eu_arn" { value = aws_s3_bucket.sites["eu-west"].arn }
local "all_arns" = values(aws_s3_bucket.sites)[*].arn
# rename a key without destroying:
moved {
from = aws_s3_bucket.sites["eu-west"]
to = aws_s3_bucket.sites["eu-west-1"]
}
Known-at-plan rule: every key must be known before apply. toset(aws_subnet.private[*].id) fails; pass IDs as variables or key on derived constants instead.
Key scheme: key by immutable ID, keep human-friendly names as values — renames then never touch identity.