Initializing Enclave...

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_each on a data source 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 nesting for_each inside a module call incorrectly.
  • How to fix it: Upgrade to Terraform ≥ 0.13, refactor using for_each on a locals map fed into the data source, or replace with count + 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:

  1. Terraform < 0.13for_each on resource blocks was introduced in 0.12.6, but for_each on data sources and full module-level support was solidified in 0.13. Running older binaries will throw this unconditionally.
  2. Provider-level restriction — Certain data source types in specific providers do not implement the for_each meta-argument interface. The provider schema rejects the argument at parse time.
  3. Structural misusefor_each placed 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 a dynamic block 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.

Related Diagnostics

"Part of the Syntax Utility Matrix."

View all 153 Syntax Tools →