Invalid for_each argument is two quite different errors sharing one heading. Read which one you have before doing anything.
Unknown keys. Terraform needs the complete set of instance addresses at plan time, and your for_each depends on something that will not exist until apply. This is a structural problem.
Wrong type. for_each accepts a map or a set of strings. You gave it a list of objects. This is a one-line fix.
The wrong type case
variable "buckets" {
type = list(object({ name = string, versioning = bool }))
}
resource "aws_s3_bucket" "this" {
for_each = var.buckets # list of object: rejected
bucket = each.value.name
}
for_each needs keys, because the key becomes part of the resource address. Project the list into a map:
resource "aws_s3_bucket" "this" {
for_each = { for b in var.buckets : b.name => b }
bucket = each.value.name
}
For a plain list of strings, toset is enough:
for_each = toset(var.bucket_names)
Note the difference this makes to addresses. With count, resources are aws_s3_bucket.this[0], and removing the first element renumbers everything after it, so Terraform destroys and recreates resources that did not change. With for_each they are aws_s3_bucket.this["logs"], and removing one leaves the others untouched. That is the main reason to prefer for_each for anything with a natural identifier.
The unknown keys case
This is the one that is genuinely hard.
resource "aws_subnet" "private" {
for_each = toset(var.azs)
# ...
}
resource "aws_route_table_association" "private" {
for_each = { for s in aws_subnet.private : s.id => s } # fails
subnet_id = each.key
route_table_id = aws_route_table.private.id
}
s.id does not exist until the subnets are created, so Terraform cannot know the keys at plan time and refuses. It has to know the full set of addresses before it makes any changes, because the plan is a promise about exactly which objects will be touched.
The fix is to key on something already known and look the unknown value up inside the body:
resource "aws_route_table_association" "private" {
for_each = toset(var.azs) # known now
subnet_id = aws_subnet.private[each.key].id # resolved at apply
route_table_id = aws_route_table.private.id
}
The rule: keys must come from variables, locals or literals. Values may come from resource attributes. Unknown values inside the body are fine, because they do not change how many instances there are or what they are called.
Finding the unknown
terraform console evaluates expressions against the current state:
> { for s in aws_subnet.private : s.id => s }
{
"(known after apply)" = ...
}
Anything showing (known after apply) in a key position is your problem.
Note that the console reads state, so this only reflects reality for resources that already exist. For a first apply it will tell you the same thing the plan did.
The data source variant
data "aws_ami" "app" {
most_recent = true
owners = ["self"]
}
resource "aws_instance" "app" {
for_each = toset(data.aws_ami.app.tags["Envs"]) # may be unknown
}
A data source that depends on a resource created in the same run cannot be read during plan, so everything it returns is unknown. Splitting the configuration so the dependency already exists is the durable answer. Alternatively, pass the values in as variables so they are known before Terraform starts.
Modules multiply this
A module whose for_each comes from another module's output inherits that output's unknown-ness:
module "app" {
source = "./modules/app"
for_each = module.platform.cluster_names # unknown on first apply
}
The platform module has not been applied yet, so its outputs are unknown, so the keys are unknown. Pass the names in from a variable instead, and let the module use them to look things up:
variable "cluster_names" {
type = set(string)
}
module "app" {
source = "./modules/app"
for_each = var.cluster_names
}
This is generally better design anyway. It makes the set of instances a declared input rather than something that emerges from the graph.
About -target
You will see terraform apply -target=... recommended for this. It does work: applying the dependency first makes the keys known, and the next plan succeeds.
HashiCorp's documentation is direct about this being for exceptional recovery, not routine use, and the reason is worth understanding. -target applies part of your configuration, which leaves state consistent with neither the old configuration nor the new one. Anyone else applying in between sees a half-built world. If a pipeline needs -target to work at all, the configuration has a dependency it should not have.
Use it to get unstuck once, then restructure.
Splitting configurations
When the dependency really is across lifecycles, the honest answer is two configurations with the second reading the first's outputs:
data "terraform_remote_state" "platform" {
backend = "s3"
config = {
bucket = "acme-tfstate"
key = "platform/terraform.tfstate"
region = "eu-west-1"
}
}
resource "aws_instance" "app" {
for_each = data.terraform_remote_state.platform.outputs.cluster_names
subnet_id = data.terraform_remote_state.platform.outputs.subnet_ids[each.key]
}
The remote state is read during plan, so the keys are known. This is the standard shape for anything where networking and applications have different change rates and different owners.
A checklist
- Read which variant it is: unknown keys, or wrong type.
- Wrong type →
toset(...)for strings,{ for x in list : x.key => x }for objects. - Unknown keys → move the resource attribute out of the key and into the body.
terraform consoleto see which part evaluates to(known after apply).- Keys from variables, locals or literals. Values may be unknown.
- Module
for_eachfrom another module's output → pass a variable instead. -targetunblocks you once. Do not put it in a pipeline.- Genuinely separate lifecycles → separate configurations plus
terraform_remote_state.
Frequently Asked Questions
Why does Terraform need to know for_each keys before apply?
Because the key becomes part of the resource address, such as aws_subnet.private["eu-west-1a"], and a plan is a precise promise about which addresses will be created, updated or destroyed. If the keys were unknown, Terraform could not tell you how many resources it was about to change, and could not detect that an object had been removed. Values inside the resource body may be unknown at plan time and appear as (known after apply), because they do not affect the identity or count of instances.
How do I fix "for_each must be a map, or set of strings"?
Convert the value. A list of strings becomes toset(var.names). A list of objects needs projecting into a map with a for expression that names a stable key: { for b in var.buckets : b.name => b }. Choose the key carefully, because changing it later destroys and recreates the resource under the new address. Something naturally stable and unique, such as a name or an identifier from the input, is the right choice; an index is not, since it reintroduces the renumbering problem for_each exists to avoid.
Can I use for_each with a value from another resource?
Inside the resource body, yes, and it is the normal way to do it. In the for_each expression itself, only if the value is already known at plan time, which rules out attributes of resources being created in the same run. The usual fix is to key on something static, such as a variable holding availability zone names, and then index into the other resource inside the body: subnet_id = aws_subnet.private[each.key].id. That keeps the instance set known while letting the actual values resolve during apply.
Is terraform apply -target a legitimate fix?
It unblocks you, and HashiCorp's own documentation describes it as a tool for exceptional recovery rather than routine use. Applying part of a configuration leaves state matching neither the previous nor the intended configuration, and anyone applying in between sees a half-built world. Use it once to get past a first apply, then restructure so the keys are known. If a pipeline needs -target on every run, that is a signal the configuration has a plan-time dependency it should not have.
Should I use count or for_each?
for_each for anything with a natural identifier, count for genuinely interchangeable copies. The difference shows up when the set changes. With count, resources are addressed by position, so removing the first element of a list renumbers every one after it and Terraform destroys and recreates resources that did not actually change. With for_each, each instance is addressed by its key, so removing one leaves the rest untouched. That behaviour is worth the extra effort of building a map.