Terraform Says for_each Keys Can't Be Known Until Apply. Here's What It Actually Wants
As of November 8, 2026, this post sums up the official pages linked here.
TL;DR: Terraform gives every for_each instance an address like aws_route_table_association.public["us-east-1a"], and it must know those keys during plan. If a key comes from something that only exists after apply, plan stops with Invalid for_each argument. The two usual causes are keys built from generated attributes, and a depends_on on a data source that pushes its read to apply time. The fix is to take keys from your config and let only the values be unknown. -target gets you past one run but doesn't fix the config.
The problem
A Reddit user was building route table associations for the public subnets behind a load balancer. Their flow went like this:
- Read the load balancer with a data source. This data block had
depends_onpointing at a Helm release, so the read would wait for the release. - Find the subnets in that VPC.
- Loop over the subnets with
for_each, and keep the ones whose Name tag contained "public".
Plan failed with the familiar message: the for_each set "includes values derived from resource attributes that cannot be determined until apply." They had already tried pushing the IDs through a terraform_data resource, which didn't help.
The error text suggests -target, so many people stop there. That gets you through one run. It doesn't explain the cause, and the error returns the next time the dependency changes. This post covers the cause, two small repros you can run with no cloud account, the fix, and a real AWS version of it.
Why Terraform needs the keys at plan time
for_each keys aren't just loop variables. They become part of each instance's address in state:
terraform_data.route_assoc["public-a"]terraform_data.route_assoc["public-b"]
A plan is a list of actions against those addresses: create this one, update that one, destroy the other. If Terraform doesn't know the keys, it can't say which instances exist, so it can't write a plan.
HashiCorp's for_each page states the rule: the keys of the map, or the values in a set of strings, must be known values. You can't build keys from impure functions such as uuid() or timestamp(), and you can't use sensitive values.
Note the asymmetry. Keys must be known. Values may be unknown. Almost every fix uses that gap.
Repro A: keys built from a generated value
# Repro A: map keys come from a value that only exists after apply.
resource "random_id" "suffix" {
for_each = toset(["logs", "assets"])
byte_length = 4
}
resource "terraform_data" "bucket" {
# Key = the random hex. Unknown at plan time, so Terraform cannot name the instances.
for_each = { for r in random_id.suffix : r.hex => r.hex }
input = "bucket-${each.value}"
}
terraform plan (trimmed):
Error: Invalid for_each argument
on main.tf line 9, in resource "terraform_data" "bucket":
9: for_each = { for r in random_id.suffix : r.hex => r.hex }
The "for_each" map includes keys derived from resource attributes that cannot be determined until apply, and so Terraform cannot determine the full set of keys that will identify the instances of this resource.
The same thing happens with AWS IDs, ARNs or any attribute the provider fills in on create.
Repro B: depends_on on a data source
This one matches the Reddit case and is harder to spot, because the data source looks harmless:
# Repro B: a data source that depends_on a managed resource is read during apply,
# so everything derived from it is unknown at plan time.
resource "terraform_data" "release" {
input = "ingress-v2"
}
data "local_file" "subnets" {
filename = "${path.module}/subnets.json"
depends_on = [terraform_data.release]
}
locals {
subnets = jsondecode(data.local_file.subnets.content)
}
resource "terraform_data" "route_assoc" {
for_each = local.subnets
input = each.value
}
subnets.json is just {"public-a": "subnet-0aa1", "public-b": "subnet-0bb2"}. The file is right there on disk, yet plan still fails:
# data.local_file.subnets will be read during apply
# (depends on a resource or a module with changes pending) <= data "local_file" "subnets" {
+ content = (known after apply)
...
Error: Invalid for_each argument
17: for_each = local.subnets
│ local.subnets will be known only after apply
Terraform's own plan output names the cause: the read waits because of a pending change. HashiCorp's data source docs explain it. Terraform tries to read data sources during plan, but it can't predict the result when the data block "directly depends on a Terraform-managed resource that is configured to change in the current plan." depends_on creates exactly that dependency. While terraform_data.release has a pending change, the file read waits for apply, so its content is unknown and so are the keys.
That is also why this bug feels random. On the box, the run went like this:
- Run
apply -target=terraform_data.release- Pending change to release? n/a - release created - Result next plan - no plan works
- change release input to
ingress-v3, then apply - Pending change to release? yes - ResultInvalid for_each argumentagain
-target fixed one run. The first real change to the dependency broke it again. In the Reddit setup, every Helm chart bump would do the same.
The fix
The full error text already hints at it: define keys statically and keep apply-time results in the values. In practice that means two rules, one for each repro:
- Keys come from config. Use names you control (a list in a variable, a
toset([...])literal, availability zone names) as keys. Put generated IDs in the values. - No
depends_onon data blocks that feedfor_each. If the data read really must happen after a change, that's a sign the data belongs in a separate configuration (see the trade-offs below).
# Fix A: keys come from config; apply-time values sit only in the map values.
resource "random_id" "suffix" {
for_each = toset(["logs", "assets"])
byte_length = 4
}
resource "terraform_data" "bucket" {
for_each = random_id.suffix # keys "logs" and "assets" are known at plan time
input = "bucket-${each.key}-${each.value.hex}"
}
# Fix B: no depends_on on the data source, so it is read during plan.
resource "terraform_data" "release" {
input = "ingress-v2"
}
data "local_file" "subnets" {
filename = "${path.module}/subnets.json"
}
locals {
subnets = jsondecode(data.local_file.subnets.content)
}
resource "terraform_data" "route_assoc" {
for_each = local.subnets
input = each.value
}
Plan now shows seven resources to add. After apply, the next plan says "No changes," and the state uses stable addresses: terraform_data.bucket["logs"], terraform_data.bucket["assets"], terraform_data.route_assoc["public-a"] and terraform_data.route_assoc["public-b"].
Note that for_each = random_id.suffix works because a resource with for_each is a map keyed by its own (known) keys, even though the attributes inside are unknown.
The same fix on real AWS resources
In the Reddit case, the real goal was "associate a route table with the public subnet in each AZ." AZ names are known before anything is created, so make them the keys and look up each subnet by AZ:
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 6.0" }
}
}
variable "vpc_id" { type = string }
variable "public_route_table_id" { type = string }
variable "public_azs" {
type = set(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
# One lookup per AZ. The keys come from the variable, so they are known at plan time.
# If a lookup has to wait until apply, only the value is unknown, and that is allowed.
data "aws_subnet" "public" {
for_each = var.public_azs
vpc_id = var.vpc_id
availability_zone = each.key
filter {
name = "tag:Tier"
values = ["public"]
}
}
resource "aws_route_table_association" "public" {
for_each = data.aws_subnet.public # same keys: the AZ names
subnet_id = each.value.id
route_table_id = var.public_route_table_id
}
The filtering moved too. Instead of looping over every subnet and testing the Name tag in an expression, the tag:Tier filter asks AWS for the right subnet directly. That shortens the config and removes the data-dependent if from the key logic.
To prove the "values may be unknown" part, the box run swapped var.vpc_id for the output of a terraform_data resource, standing in for a VPC created in the same apply. With no real AWS account involved, plan still succeeded:
# data.aws_subnet.public["us-east-1a"] will be read during apply
# (config refers to values not yet known)
...
# aws_route_table_association.public["us-east-1a"] will be created
# aws_route_table_association.public["us-east-1b"] will be created
# aws_route_table_association.public["us-east-1c"] will be created
Plan: 4 to add, 0 to change, 0 to destroy.
The subnet lookups wait for apply, but the association addresses are fixed, so Terraform can plan.
Trade-offs
You must know the keys up front. Static keys mean listing AZs, environment names or service names in config. If the set truly changes outside Terraform (say, subnets someone else creates), a data source read at plan time is fine, but only if it doesn't depend on a pending change in the same run.
Changing a key is a replace. Renaming "logs" to "log-archive" destroys one instance and creates another. Use a moved block when you rename keys on purpose.
-target is for emergencies. HashiCorp's plan docs keep targeting for exceptional cases, like recovering from mistakes, and warn that routine use can hide configuration drift. A bootstrap that always needs -target will catch someone out later.
Splitting configurations is sometimes right. If something must exist first (for example, a load balancer created by a Helm release), put it in its own root module and pass its outputs to the second one. That adds a pipeline stage and remote state reads, but each plan is honest.
Don't launder unknowns. Passing a value through terraform_data, locals or a module output doesn't make it known. If the source is unknown, everything built from it is too.
How to verify the fix
- Run plan with no special flags.
terraform plan(no-target) should finish withoutInvalid for_each argument. - Look for deferred reads. Search the plan for
will be read during apply. If a data block that feedsfor_eachkeys shows up there, the bug is still waiting for the next change. - Change the dependency and plan again. In the repro, that meant editing
terraform_data.release. In a real stack, bump the Helm chart version or a tag on the resource you used to depend on. A correct config still plans. - Check the addresses. After apply,
terraform state listshould show readable keys you chose (["us-east-1a"],["logs"]), not IDs or random hex. - Plan once more after apply. You want "No changes." If the keys shift between runs, something non-deterministic is still in the key path.
All five checks passed on the box with Terraform v1.9.8. The three plans (both repros and the fix) gave the same results on v1.17.0.
A Terraform codebase full of -target notes may have this exact pattern underneath. In CloudSkill Consulting LLC software engineering reviews, we'd fix the keys once rather than keep repeating the workaround.
Docs checked (October 26, 2026)
- The
for_eachmeta-argument (limitations on values, impure functions, sensitive values) - Data sources (when reads are deferred to apply)
- The
depends_onmeta-argument terraform plancommand (resource targeting guidance)- The
terraform_dataresource type - Refactoring with
movedblocks - AWS provider:
aws_subnetdata source andaws_route_table_association
When HashiCorp changes how deferred reads or for_each keys behave, email i***@cloudskillconsulting.com and we'll bring this article up to date. CloudSkill Consulting LLC helps teams with fixed-fee cloud projects, FinOps reviews and IT staffing. Learn more at cloudskillconsulting.com.
Comments
No comments yet. Start the discussion.