Skip to content

Terraform for_each: prove keys in an empty-state plan

A green plan against established state can hide unknown instance keys. Isolate the language behavior before changing the module interface.

Dopstack Engineering

· 9 min read

Original conceptual illustration: Fixed east and west name tabs remain visible above offset cards whose identifiers are still unknown.

Quick answer

Terraform for_each needs known instance keys during planning, but the arguments inside each instance can remain computed. Keep configuration-owned role names as map keys and generated identifiers in values. Test with empty state, including filters that affect membership, because existing producer state can conceal a module that cannot bootstrap normally.

Terraform for_each needs the instance keys during planning, even when arguments inside those instances will only be known after apply. Repair an unknown-key error by separating configuration-owned addresses from computed payloads. Then test the configuration with empty state: an existing producer can make a broken design appear to work. A successful warm plan does not prove that a new environment can be created. [1][2]

Which value is Terraform trying to know?#

Terraform needs to identify the instances whose changes it proposes. In a map-based for_each, a key becomes part of an address. A cloud-generated identifier can be a perfectly good argument for a resource while being unsuitable as the key that identifies that resource. The error concerns the collection's shape, not a general ban on computed arguments. [1][2]

Consider an illustrative module that creates regional service endpoints and then configures consumers for them. The team chooses names such as east and west; the provider supplies endpoint identifiers later. Those facts belong in different places. The names can define the desired consumer population, while the returned identifiers become the values passed to each consumer.

This distinction matters to module reviewers because the failing line often looks like a harmless conversion. A list of identifiers becomes a set, or a producer list becomes a map keyed by ID. That transformation also moves a remote result into the addressing scheme. A map still fails when its key comes from the generated ID. [1]

The experiment below uses built-in terraform_data resources to isolate the language behavior. They require no configured external provider. The producer's generated ID gives us a computed value without needing a cloud account, credentials or real service endpoints. It is a small reproduction of the planning problem, not a substitute for testing your provider's own behavior. [5]

Reproduce the failure without a cloud account#

Create a disposable directory with the following configuration and no state. Do not run the experiment in your production working directory. The named producers have known instance keys, but the proposed consumer set is made from identifiers that have not been generated yet. That is the deliberate defect. [1][5]

hcl
terraform {
  required_version = ">= 1.4.0"
}

resource "terraform_data" "endpoint" {
  for_each = toset(["east", "west"])
  input    = each.key
}

resource "terraform_data" "consumer" {
  for_each = toset([
    for endpoint in terraform_data.endpoint : endpoint.id
  ])
  input = each.value
}

Run terraform init -backend=false, followed by terraform plan -input=false -no-color. Inspect the diagnostic rather than treating the command's exit code as the entire test. The broken consumer should fail because its instance keys cannot be determined. The producer declarations themselves are not the unknown population; their generated IDs are. [1][5]

Why does knowing the list's apparent length not fix the set? Set members are also the instance keys, and computed values do not provide the required identities. There is an additional practical ambiguity when values can coalesce. A known number of upstream declarations is not permission to invent addresses for downstream set members. Keep the stable business names instead of asking a conversion function to create certainty. [1]

Retain the failed plan with the fixture. It proves that the reproduction still exercises the intended failure after a future refactor. A test that only checks the repaired form could quietly stop supplying unknown values, leaving the module's important assumption untested. This is our suggested test design, rather than a Terraform test framework requirement.

How does a stable map repair the plan?#

Keep east and west as the consumer keys, and move the producer ID into the map's value. Terraform can then identify each consumer before the ID exists. The plan may still display unknown arguments. That is expected: an unknown payload and an unknown instance address have different consequences. [1][2]

Replace only the consumer block with this version. It constructs a map whose keys come from the already-known producer keys. The address is consequently understandable to a reviewer, while the data dependency on the producer remains explicit in the expression. [1]

hcl
resource "terraform_data" "consumer" {
  for_each = {
    for name, endpoint in terraform_data.endpoint :
    name => endpoint.id
  }
  input = each.value
}

The acceptance condition is a normal plan that proposes the named consumers while leaving their IDs as computed inputs. Do not judge the repair by whether (known after apply) disappears from the output. Terraform deliberately represents unavailable results that way. The useful question is whether the unknown result is still deciding membership or identity. [2]

In a real module, write the input as a map of objects with stable labels and computed attributes. An endpoint's remote ID, ARN or assigned address belongs in an object's value. The module's caller should own the label's meaning. Avoid renaming labels casually: these names are addresses in Terraform state, not merely descriptions in a dashboard. [1]

Our instance mapping guide covers a different task: preserving objects while changing an existing addressing scheme. The present repair starts earlier, by making sure the desired instance scheme can be planned in a fresh environment. A cold-plan fix is not authorization to re-address deployed resources without examining their migration.

Can a map still have unknown membership?#

Yes. Known key expressions are insufficient when an unknown filter decides whether those keys exist. Review both the key and the condition that admits an entry. Otherwise, replacing a set with a map can preserve the same planning defect behind different syntax. [1][2]

For example, the following illustrative expression still asks a generated ID to decide whether to create a consumer. The literal name is stable, but the predicate must be evaluated before Terraform knows the collection's membership. An empty string comparison does not establish that membership in the cold plan. [1][2]

hcl
locals {
  selected_endpoints = {
    for name, endpoint in terraform_data.endpoint :
    name => endpoint.id
    if endpoint.id != ""
  }
}

resource "terraform_data" "filtered_consumer" {
  for_each = local.selected_endpoints
  input    = each.value
}

Move the eligibility decision to a configuration-owned input when that matches the product's intent. For example, the caller can supply an enabled region map, and the module can create exactly those regional resources. The generated identifier then fills in the configured resource rather than deciding whether that resource should exist. This is a contract decision, not a universal mechanical rewrite.

Sometimes membership truly is determined by a remote operation. The cloud service may decide how many endpoints exist or return a discovered set whose contents cannot be predicted. In that case, pretending the collection is static misrepresents the system. Separate creation and discovery into explicit lifecycle stages, with the output and ownership of each stage recorded. The extra stage is a real operational trade-off.

Proposed repairWhat it resolvesWhat still needs review
Stable named mapAddresses known during planningPayload types and provider behavior
Known enabled-map inputMembership chosen by configurationCaller owns the eligibility decision
count with known lengthPositional populationReordering and positional identity
Separate discovery stageRemote membership becomes an explicit inputAdditional state and orchestration
Broad depends_onHidden execution dependencyDoes not manufacture known keys
Plan-time identity repair choices

Why do targeting and depends_on disappoint?#

Targeting can make a subsequent plan work by creating the producer first, but it does not prove the consumer contract supports a fresh ordinary plan. HashiCorp documents targeting as an exceptional operation. If a module always needs a bootstrap sequence, describe and operate that sequence deliberately rather than hiding it in a troubleshooting command. [6]

An added depends_on also solves a different problem. It expresses a hidden behavioral dependency that Terraform cannot infer from data references. Here, the consumer already references the producer. Making the dependency broader does not provide the generated ID during planning, and broad dependencies can make plans more conservative. Use the actual data reference where it expresses the relationship. [3]

Switching to count deserves equal care. It uses positional instance identities. A known count can be appropriate for interchangeable positions, but it does not preserve the semantics of stable named roles automatically. Check what removing or reordering input entries means before choosing it as the fastest way to get a green plan. [4]

Review the collection's owner before changing another meta-argument. Ask who chooses the population and who chooses the values. If configuration chooses the population, use that configuration to construct the addresses. If a remote operation chooses it, acknowledge the lifecycle boundary and test its recovery behavior.

What should the module's release test prove?#

The release test should prove that the module works with unknown producer payloads in a fresh working directory, not just with literal mock IDs or an established state snapshot. Keep a negative fixture alongside the repaired fixture, and confirm that the negative case still fails for the expected unknown-key reason.

Include a membership-filter case, because it exercises a different expression path. Include the normal provider-backed module test separately: the built-in fixture does not establish IAM permissions, eventual consistency or resource replacement behavior. The language test should remain small enough that a reviewer can explain why it passes without reading a cloud deployment log.

Capture the Terraform version, initialization result, diagnostic text and normal-plan outcome. Store experimental state and plans only in the disposable fixture directory. An empty-state test means selecting a separate directory or test harness, not deleting state from a deployed environment. The latter changes the relationship between Terraform and existing infrastructure rather than recreating this experiment.

When the real module is ready, examine the full plan and use your established approval process. Our saved-plan guide explains how to bind approval to the artifact that will be applied. Passing the language fixture answers a narrow question about plan-time identity; it does not approve the eventual infrastructure change.

How do you keep a cold-plan test cold?#

Keep the fixture in a fresh directory and record that it has no existing state. A producer ID read from an earlier experiment can turn the important unknown into a known value. If a repaired fixture passes only after a targeted producer apply, it has not demonstrated the normal cold-plan contract this test is intended to check. [1][2][6]

Use separate directories for the deliberately broken set, the stable-map repair and the unknown-membership filter. That prevents a successful experiment from supplying state to its negative neighbor. Initialize each with the built-in provider, run the normal plan and retain the diagnostic. No apply is necessary to establish the planning difference. [5]

For the fixed map, inspect the proposed addresses as well as success. The names should be the configured roles. If the test accidentally substitutes literal endpoint IDs for the producer's computed IDs, it may still pass while no longer exercising an unknown payload. The fixture must retain known role keys and computed argument values.

For the filter, check the cause of failure. A syntax error, incompatible version or unrelated initialization failure does not count as a successful negative test. Store both the expected diagnostic class and the actual output. This distinction lets the test remain meaningful when a tool upgrade changes wording without changing the language constraint.

Then review the real module for input order and renames. A test that covers initial creation does not cover every future address transition. Keep those migration tests separate so a green cold plan cannot conceal a planned replacement of an existing named resource.

What should reviewers ask next?#

Are all unknown values errors in Terraform?
No. Terraform represents values that cannot yet be predicted with unknown placeholders. Arguments can remain computed while a plan is useful. The important distinction here is whether an unknown value decides which resource instances exist or merely supplies data to known instances. [1][2]
Does using a map always fix for_each?
No. Its keys and membership must be knowable during planning. A filter based on a generated attribute can keep membership unknown even if the proposed key is a literal name. Inspect the entire expression rather than its final collection type. [1][2]
Is terraform_data a replacement for a provider test?
No. It supplies a built-in resource lifecycle suitable for isolating language behavior. It does not exercise a cloud API, its permissions or its resource semantics. Retain provider-backed tests for those concerns and report the narrower fixture's coverage accurately. [5]
Should a working warm plan be accepted?
It is useful evidence about existing state, but it does not establish that an empty environment can be created normally. Test the unknown-producing path separately, especially for reusable modules whose callers may create their producers and consumers together. [1][2]

Technologies

  • Terraform
  • terraform_data

Sources

  1. [1]
    for_each reference, HashiCorp

    Primary source read 31 October 2026. Publication/update date not listed unless stated; live documentation, examples are illustrative.

  2. [2]
    References to Values, HashiCorp

    Primary source read 31 October 2026. Publication/update date not listed unless stated; live documentation, examples are illustrative.

  3. [3]
    depends_on reference, HashiCorp

    Primary source read 31 October 2026. Publication/update date not listed unless stated; live documentation, examples are illustrative.

  4. [4]
    count reference, HashiCorp

    Primary source read 31 October 2026. Publication/update date not listed unless stated; live documentation, examples are illustrative.

  5. [5]
    terraform_data resource reference, HashiCorp

    Primary source read 31 October 2026. Publication/update date not listed unless stated; live documentation, examples are illustrative.

  6. [6]
    terraform plan command, HashiCorp

    Primary source read 31 October 2026. Publication/update date not listed unless stated; live documentation, examples are illustrative.

Written by

Dopstack Engineering

Engineering team

The engineering team at Dopstack. Illustrative scenarios are composites written to explain a pattern, not accounts of client work.

All articles Website

Get new articles by email

Engineering notes on AI, cloud and SaaS. Occasional, never sold or shared.

Send me new Dopstack Insights articles by email. I can unsubscribe at any time. We’ll email you once to confirm. Privacy

Continue reading