Initializing Enclave...

How to Fix Terraform 'Error: Invalid for_each Set Value' Caused by Null in a Set

Threat/Impact Level: HIGH | Downtime Risk: HIGH | Time to Fix: 5–15 mins


TL;DR

  • What broke: Terraform's for_each received a set containing at least one null element — this is a hard runtime error; Terraform refuses to proceed and the entire apply or plan aborts.
  • How to fix it: Filter nulls out of the collection before it reaches for_each using compact(), a for expression with an if guard, or toset([ for v in var.items : v if v != null ]).
  • Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your failing .tf block and get corrected HCL without sending your state or secrets anywhere.

The Incident (What Does the Error Mean?)

You hit this during terraform plan or terraform apply:

Error: Invalid for_each set value

  on main.tf line 12, in resource "aws_iam_role_policy_attachment" "this":
  12:   for_each = toset(var.policy_arns)

Invalid single element for a set: element index 2 is null.
Only non-null values may be used as set element keys.

Immediate consequence: The entire Terraform operation halts. No resources in the run are created, updated, or destroyed — including unrelated resources later in the graph. In a pipeline, this breaks the deployment stage completely. If this is a partial re-apply after a failed run, your state may already have diverged.


The Attack Vector / Blast Radius

This is not just a syntax annoyance. The blast radius is:

  • Pipeline hard-stop. Every downstream job depending on Terraform outputs (terraform output) gets null or stale values. Services expecting freshly provisioned ARNs, IPs, or IDs break silently or loudly depending on how your pipeline handles exit codes.
  • State drift. If this apply was mid-flight (e.g., some resources already created in a prior partial run), you now have real infrastructure with no corresponding state lock. Drift accumulates.
  • Root cause is almost always dynamic input. The null sneaks in from: an optional variable with no default, a lookup() that missed a key, a data source attribute that returned null because the resource doesn't exist yet, or a list built from jsondecode() on a sparse JSON blob. The set type in Terraform does not tolerate nulls — ever.
  • toset() does not sanitize. Many engineers assume toset() will coerce or drop nulls. It does not. It hard-errors.

How to Fix It (The Solution)

Basic Fix — Filter Nulls Before toset()

The minimal surgical fix: use a for expression with a null guard.

 resource "aws_iam_role_policy_attachment" "this" {
-  for_each = toset(var.policy_arns)
+  for_each = toset([for arn in var.policy_arns : arn if arn != null])

   role       = aws_iam_role.this.name
   policy_arn = each.value
 }

For list(string) inputs that may contain empty strings as well as nulls, use compact() (drops both nulls and empty strings):

 resource "aws_iam_role_policy_attachment" "this" {
-  for_each = toset(var.policy_arns)
+  for_each = toset(compact(var.policy_arns))

   role       = aws_iam_role.this.name
   policy_arn = each.value
 }

⚠️ compact() only works on list(string). If your set contains objects or complex types, use the for expression with if v != null.


Enterprise Best Practice — Validate at the Variable Boundary

Don't let nulls propagate into your module internals. Enforce non-null at the variable declaration using validation blocks, and use a nullable-safe default.

 variable "policy_arns" {
-  type    = list(string)
-  default = []
+  type    = list(string)
+  default = []
+
+  validation {
+    condition     = alltrue([for arn in var.policy_arns : arn != null && arn != ""])
+    error_message = "policy_arns must not contain null or empty string values."
+  }
 }

For module authors publishing reusable modules, always defensively sanitize inside the module even if you validate at the boundary — consumers will pass bad data:

 locals {
-  policy_set = toset(var.policy_arns)
+  policy_set = toset([for arn in var.policy_arns : arn if arn != null && arn != ""])
 }

 resource "aws_iam_role_policy_attachment" "this" {
   for_each   = local.policy_set
   role       = aws_iam_role.this.name
   policy_arn = each.value
 }

💡 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

Stop this class of error before it ever reaches plan.

1. terraform validate in pre-commit Add terraform validate as a pre-commit hook. It catches type mismatches early, though it won't catch runtime-null issues from dynamic data sources.

2. Checkov — static analysis for Terraform Checkov won't catch this specific null issue natively, but custom checks can be written:

checkov -d . --check CKV_TF_FOR_EACH_NULL

Write a custom Checkov Python check that parses HCL AST and flags any toset(var.*) call without a null filter.

3. OPA/Conftest policy Enforce in your pipeline with a Rego policy that rejects plans where for_each expressions reference raw variable sets without a compact or null-filter expression:

terraform show -json tfplan.binary | conftest test - --policy ./policies/foreach_null.rego

4. tflint Use tflint with the terraform ruleset. Add a custom rule to flag toset(var.*) patterns:

tflint --enable-rule=terraform_required_version

5. Strongly-typed variable defaults Never declare list(string) variables without a non-null default and a validation block. Make nulls impossible at the input layer, not just at the resource layer.

variable "policy_arns" {
  type        = list(string)
  default     = []
  nullable    = false   # Terraform 1.1+ — prevents null being passed as the entire variable
}

nullable = false (Terraform ≥ 1.1) prevents a caller from passing null as the variable value itself — it falls back to the default. This does not prevent null elements inside the list, which is why the for expression guard is still required.

Related Diagnostics

"Part of the Syntax Utility Matrix."

View all 153 Syntax Tools →