How to Fix Terraform 'Unsupported Argument for_each' in Data Sources (With Working Examples)
Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 10 mins
TL;DR
- What broke: You used
for_eachon adatasource block in a Terraform version or context where it is not supported — either the provider/resource type disallows it, your Terraform binary is pre-0.13, or you are nestingfor_eachinside a module call incorrectly. - How to fix it: Upgrade to Terraform ≥ 0.13, refactor using
for_eachon alocalsmap fed into the data source, or replace withcount+ index-based lookup depending on the data source type. - Fast path: Use our Client-Side Sandbox above to auto-refactor this — paste your failing data source block and get corrected HCL instantly without leaking your config to external AI logs.
The Incident (What Does the Error Mean?)
Raw error output from terraform plan or terraform apply:
Error: Unsupported argument
on main.tf line 12, in data "aws_iam_policy_document" "example":
12: for_each = var.policy_map
An argument named "for_each" is not expected here.
Immediate consequence: Terraform exits with a non-zero code. No plan is generated. No apply proceeds. Every downstream resource depending on this data source is blocked. In a CI/CD pipeline, this kills the entire deployment stage.
This error surfaces in three distinct failure modes:
- Terraform < 0.13 —
for_eachon resource blocks was introduced in 0.12.6, butfor_eachondatasources and full module-level support was solidified in 0.13. Running older binaries will throw this unconditionally. - Provider-level restriction — Certain data source types in specific providers do not implement the
for_eachmeta-argument interface. The provider schema rejects the argument at parse time. - Structural misuse —
for_eachplaced inside the body of a data source (as a provider-specific argument) rather than as a Terraform meta-argument at the block level, or used within adynamicblock incorrectly.
The Attack Vector / Blast Radius
This is not a silent failure — it is a hard stop. But the blast radius is significant in these scenarios:
Pipeline Lockout: In a GitOps workflow (Atlantis, Terraform Cloud, GitHub Actions), this error causes the plan job to fail and block the PR merge. If the broken config is already in main and your workspace is set to auto-apply, you have just frozen all infrastructure changes until the fix is merged. No hotfixes, no rollbacks via Terraform, nothing moves.
State Drift Risk: Engineers under pressure will sometimes run terraform apply with -target flags to work around the broken data source, creating state drift — resources get created outside the full dependency graph. This leads to phantom resources, orphaned security groups, and IAM roles that never get cleaned up.
Version Mismatch in Teams: If one engineer has Terraform 0.15 locally and the CI runner uses 0.12 (a common oversight without .terraform-version or tfenv), this error only appears in CI. The engineer's local plan succeeds, they merge, CI explodes. The feedback loop is broken and trust in the pipeline erodes.
Data Source Dependency Chain: A data source with for_each is often feeding a resource block's for_each. When the data source fails, the entire resource instantiation tree — potentially dozens of security groups, IAM bindings, or DNS records — is unresolvable.
How to Fix It (The Solution)
Root Cause Checklist (Run These First)
# Check your Terraform version — must be >= 0.13 for reliable for_each on data sources
terraform version
# Check the provider version and its changelog for data source meta-argument support
terraform providers
Basic Fix — Correct for_each Placement at Block Level
The most common mistake: for_each written as if it were a provider argument inside the block body.
- data "aws_subnet" "selected" {
- for_each = var.subnet_ids # WRONG: treated as provider argument, not meta-argument
- id = each.value
- }
+ data "aws_subnet" "selected" {
+ for_each = toset(var.subnet_ids) # CORRECT: meta-argument at block scope
+ id = each.value
+ }
Note: for_each must receive a map or set of strings. Passing a plain list will throw a secondary error. Always wrap lists with toset() or convert to a map using { for idx, val in var.list : val => idx }.
Fix for Terraform < 0.13 — Use count + element()
If upgrading Terraform is not immediately possible (legacy pipeline, locked binary):
- data "aws_iam_policy_document" "by_role" {
- for_each = var.role_map
- statement {
- actions = each.value.actions
- resources = each.value.resources
- }
- }
+ locals {
+ role_list = values(var.role_map)
+ }
+
+ data "aws_iam_policy_document" "by_role" {
+ count = length(local.role_list)
+ statement {
+ actions = local.role_list[count.index].actions
+ resources = local.role_list[count.index].resources
+ }
+ }
Tradeoff: count-based resources are addressed by index (data.aws_iam_policy_document.by_role[0]). Reordering var.role_map will cause Terraform to destroy and recreate resources. This is why upgrading to for_each (key-addressed) is the correct long-term fix.
Enterprise Best Practice — Locals-Normalized Map with Validation
In production, never pass raw var directly into for_each on a data source. Normalize through locals with explicit type validation:
- data "aws_secretsmanager_secret" "app_secrets" {
- for_each = var.secret_names
- name = each.value
- }
+ locals {
+ # Enforce map shape; prevents accidental list input causing for_each type errors
+ secret_map = { for name in toset(var.secret_names) : name => name }
+ }
+
+ data "aws_secretsmanager_secret" "app_secrets" {
+ for_each = local.secret_map
+ name = each.value
+ }
+
+ # Reference downstream:
+ # data.aws_secretsmanager_secret.app_secrets["my-db-password"].arn
Why this matters at scale: When var.secret_names is populated from a remote state output or a tfvars file managed by another team, its type can silently change from set(string) to list(string) or tuple. The toset() normalization in locals is your last line of defense before Terraform throws a type mismatch on for_each.
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
1. Pin Terraform Version with .terraform-version
# .terraform-version (used by tfenv automatically)
1.6.6
Add to your repo root. Every engineer and every CI runner using tfenv will use the identical binary. No more "works on my machine" version skew.
2. Checkov — Static Analysis for Meta-Argument Misuse
# .github/workflows/terraform-lint.yml
- name: Run Checkov
uses: bridgecrewio/checkov-action@master
with:
directory: ./terraform
framework: terraform
# Checkov parses HCL and catches structural errors before plan
3. terraform validate as a Pre-Commit Hook
# .pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.83.5
hooks:
- id: terraform_validate
- id: terraform_fmt
- id: terraform_tflint
terraform validate catches Unsupported argument for_each before the code is ever committed. This is the cheapest possible fix — zero CI minutes burned.
4. TFLint Rule for Version Compatibility
# .tflint.hcl
plugin "terraform" {
enabled = true
preset = "recommended"
}
rule "terraform_required_version" {
enabled = true
}
TFLint's terraform_required_version rule enforces that your required_version constraint in terraform {} blocks is explicit and matches your pinned binary, catching compatibility issues at lint time.
5. OPA/Conftest Policy — Block Plans with Unresolved Data Sources
# policy/deny_unresolved_data.rego
package terraform.deny
deny[msg] {
resource := input.planned_values.root_module.resources[_]
resource.mode == "data"
# If a data source value is unknown at plan time due to for_each errors,
# its values will be null — catch this before apply
resource.values == null
msg := sprintf("Data source '%s' has null values — likely a for_each resolution failure.", [resource.address])
}
Run via conftest test --policy policy/ tfplan.json in your pipeline after terraform show -json tfplan > tfplan.json.