Terraform flatten with Nested Maps and for_each

Tested on Ubuntu 26.04 LTS (Resolute Raccoon)
Package terraform 1.15.8-1
Applies to Any host with Terraform installed
Lab environment Single Ubuntu VM with Terraform — Terraform lab environment on Ubuntu
Privilege Normal user
Scope Nested map input structures, nested for expressions producing list of lists, flatten to a single list, conversion to a keyed map for for_each, terraform_data instance addresses, key stability when collection membership changes, and common duplicate-key, type, and flatten misuse errors. Does not cover every collection function or dynamic blocks.
Related guides count and for_each
Terraform expressions
Terraform functions
Invalid for_each argument fixes
terraform console

for_each needs a map or set of strings, but module inputs often arrive as nested maps — apps containing servers, environments containing subnets. You walk the nesting with for expressions, collapse the result with flatten(), then build stable string keys so each leaf becomes one resource instance.

text
nested map  →  nested for  →  list of lists  →  flatten  →  keyed map  →  for_each

The full walkthrough lives in ~/terraform-labs/terraform-flatten-nested-map/. Examples use terraform_data so you can plan and apply without cloud credentials.

NOTE
Run terraform init in each new directory. For the broader flatten function catalog, see Terraform functions — this article focuses on the nested-map-to-for_each pipeline.

Start with a nested data structure

Model apps as a map of objects, each holding a map of servers. Three servers sit under two apps. You want one Terraform resource per server, not one block per app.

Create the lab root and write the variable block:

bash
mkdir -p ~/terraform-labs/terraform-flatten-nested-map
cd ~/terraform-labs/terraform-flatten-nested-map
bash
cat > variables.tf <<'EOF'
variable "apps" {
  type = map(object({
    tier    = string
    servers = map(object({
      hostname = string
      port     = number
    }))
  }))
  default = {
    web = {
      tier = "frontend"
      servers = {
        primary = { hostname = "web01.lab.local", port = 80 }
        backup  = { hostname = "web02.lab.local", port = 8080 }
      }
    }
    api = {
      tier = "backend"
      servers = {
        primary = { hostname = "api01.lab.local", port = 3000 }
      }
    }
  }
}
EOF

Transform with nested for expressions

A single for over var.apps only reaches the app level. Nest a second for over app.servers to emit one object per server while keeping app context. The outer comprehension produces one inner list per app — a list of lists, not yet usable with for_each.

Write locals.tf with the nested for, the flatten step, and the keyed-map conversion you will use later:

bash
cat > locals.tf <<'EOF'
locals {
  server_matrix = [
    for app_name, app in var.apps : [
      for srv_name, srv in app.servers : {
        app_name = app_name
        app_tier = app.tier
        srv_name = srv_name
        hostname = srv.hostname
        port     = srv.port
      }
    ]
  ]

  servers_flat = flatten(local.server_matrix)

  servers_by_key = {
    for s in local.servers_flat : "${s.app_name}/${s.srv_name}" => s
  }
}
EOF

Initialize the lab directory:

bash
terraform init -input=false

Inspect the nested shape in terraform console:

bash
printf '%s\n' 'local.server_matrix' | terraform console -no-color
output
[
  [
    {
      "app_name" = "api"
      "app_tier" = "backend"
      "hostname" = "api01.lab.local"
      "port" = 3000
      "srv_name" = "primary"
    },
  ],
  [
    {
      "app_name" = "web"
      "app_tier" = "frontend"
      "hostname" = "web02.lab.local"
      "port" = 8080
      "srv_name" = "backup"
    },
    {
      "app_name" = "web"
      "app_tier" = "frontend"
      "hostname" = "web01.lab.local"
      "port" = 80
      "srv_name" = "primary"
    },
  ],
]

Two top-level list elements correspond to two apps; the web inner list holds two server objects.


Flatten the nested list

flatten() recursively replaces directly nested list elements with their contents, producing one flat list. It does not descend indirectly through map or object attributes.

local.servers_flat in locals.tf applies flatten() to local.server_matrix. Console shows a single list of three server objects:

bash
printf '%s\n' 'local.servers_flat' | terraform console -no-color
output
[
  {
    "app_name" = "api"
    "app_tier" = "backend"
    "hostname" = "api01.lab.local"
    "port" = 3000
    "srv_name" = "primary"
  },
  {
    "app_name" = "web"
    "app_tier" = "frontend"
    "hostname" = "web02.lab.local"
    "port" = 8080
    "srv_name" = "backup"
  },
  {
    "app_name" = "web"
    "app_tier" = "frontend"
    "hostname" = "web01.lab.local"
    "port" = 80
    "srv_name" = "primary"
  },
]

flatten collapsed the per-app lists into one sequence. It did not read inside map values — you still need the nested for expressions to pull objects out of var.apps first.


Convert flattened list into a map

for_each cannot consume a list. local.servers_by_key in locals.tf builds a map with a for expression and a stable composite key:

hcl
servers_by_key = {
    for s in local.servers_flat : "${s.app_name}/${s.srv_name}" => s
  }

The ${app}/${server} pattern stays unique across apps and gives human-readable state addresses.

List the keys console will hand to for_each:

bash
printf '%s\n' 'keys(local.servers_by_key)' | terraform console -no-color
output
[
  "api/primary",
  "web/backup",
  "web/primary",
]

Three distinct keys for three servers — the shape for_each expects.


Use the result with for_each

Wire the keyed map into a resource block:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

resource "terraform_data" "server" {
  for_each = local.servers_by_key

  input = {
    app_name = each.value.app_name
    app_tier = each.value.app_tier
    srv_name = each.value.srv_name
    hostname = each.value.hostname
    port     = each.value.port
  }
}
EOF

Plan before apply to see keyed instance addresses:

bash
terraform plan -no-color -input=false
output
# terraform_data.server["api/primary"] will be created
  + resource "terraform_data" "server" {
      + input = {
          + app_name = "api"
          + app_tier = "backend"
          + hostname = "api01.lab.local"
          + port     = 3000
          + srv_name = "primary"
        }
    }

  # terraform_data.server["web/backup"] will be created
  + resource "terraform_data" "server" {
      + input = {
          + app_name = "web"
          + app_tier = "frontend"
          + hostname = "web02.lab.local"
          + port     = 8080
          + srv_name = "backup"
        }
    }

  # terraform_data.server["web/primary"] will be created
  + resource "terraform_data" "server" {
      + input = {
          + app_name = "web"
          + app_tier = "frontend"
          + hostname = "web01.lab.local"
          + port     = 80
          + srv_name = "primary"
        }
    }

Plan: 3 to add, 0 to change, 0 to destroy.

Each plan line names the for_each key in brackets — that key becomes the permanent state address.

Apply and confirm instances landed in state:

bash
terraform apply -auto-approve -input=false
output
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

List the keyed addresses Terraform recorded:

bash
terraform state list
output
terraform_data.server["api/primary"]
terraform_data.server["web/backup"]
terraform_data.server["web/primary"]

Inside the resource block, each.key is the map key (web/primary) and each.value is the full server object.


Preserve stable resource identity

Stable keys matter when the input collection changes. If you keyed instances by list index, removing a middle element would shift every later index and force unnecessary destroys and creates.

This lab uses variables-key-stability-after-remove.tfvars, which drops web.backup while keeping web.primary and api.primary. Create that file after the three-server apply:

bash
cat > variables-key-stability-after-remove.tfvars <<'EOF'
apps = {
  web = {
    tier = "frontend"
    servers = {
      primary = {
        hostname = "web01.lab.local"
        port     = 80
      }
    }
  }

  api = {
    tier = "backend"
    servers = {
      primary = {
        hostname = "api01.lab.local"
        port     = 3000
      }
    }
  }
}
EOF

Plan with that file:

bash
terraform plan -no-color -input=false -var-file=variables-key-stability-after-remove.tfvars
output
# terraform_data.server["web/backup"] will be destroyed
  # (because key ["web/backup"] is not in for_each map)
  - resource "terraform_data" "server" {
      - input = {
          - app_name = "web"
          - hostname = "web02.lab.local"
          - srv_name = "backup"
        } -> null
    }

Plan: 0 to add, 0 to change, 1 to destroy.

Only the removed key is destroyed. web/primary and api/primary stay untouched because their keys still exist in local.servers_by_key.

Avoid keys built from values that stay unknown until apply (for example a resource ID assigned at create time). for_each keys must be known during planning — see known after apply when keys depend on computed attributes.


Common errors

Most mistakes appear at plan time once locals are evaluated.

Duplicate composite keys

Two flattened rows that share the same ${app_name}/${srv_name} collide when building servers_by_key:

bash
mkdir -p ~/terraform-labs/terraform-flatten-nested-map/errors/duplicate-keys
cd ~/terraform-labs/terraform-flatten-nested-map/errors/duplicate-keys
bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

locals {
  servers_flat = [
    { app_name = "web", srv_name = "primary", hostname = "web01.lab.local", port = 80 },
    { app_name = "web", srv_name = "primary", hostname = "web02.lab.local", port = 8080 },
  ]
  servers_by_key = {
    for s in local.servers_flat : "${s.app_name}/${s.srv_name}" => s
  }
}
EOF

Initialize that error directory:

bash
terraform init -input=false

Plan surfaces the duplicate before any resource is created:

bash
terraform plan -no-color -input=false
output
Error: Duplicate object key

  on main.tf line 11, in locals:
  10:   servers_by_key = {
  11:     for s in local.servers_flat : "${s.app_name}/${s.srv_name}" => s

Two different items produced the key "web/primary" in this 'for' expression.

Include enough key segments (region, environment, or a unique server id) so every row maps to a distinct string.

List passed directly to for_each

After flatten, the result is still a list. Passing it straight to for_each fails even though validate may pass:

bash
mkdir -p ~/terraform-labs/terraform-flatten-nested-map/errors/list-for-each
cd ~/terraform-labs/terraform-flatten-nested-map/errors/list-for-each
bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

locals {
  servers_flat = [
    {
      app_name = "web"
      app_tier = "frontend"
      srv_name = "primary"
      hostname = "web01.lab.local"
      port     = 80
    },
  ]
}

resource "terraform_data" "server" {
  for_each = local.servers_flat

  input = {
    app_name = each.value.app_name
    app_tier = each.value.app_tier
    srv_name = each.value.srv_name
    hostname = each.value.hostname
    port     = each.value.port
  }
}
EOF
bash
terraform init -input=false

Validate accepts the HCL syntax, but plan rejects the meta-argument type:

bash
terraform plan -no-color -input=false
output
Error: Invalid for_each argument

  on main.tf line 18, in resource "terraform_data" "server":
  18:   for_each = local.servers_flat
    ├────────────────
    │ local.servers_flat is tuple with 1 element

The given "for_each" argument value is unsuitable: the "for_each" argument
must be a map, or set of strings, and you have provided a value of type tuple.

Always add the keyed-map for expression between flatten and for_each.

flatten does not recurse into maps

flatten() recursively collapses directly nested lists into one flat list. It does not walk map values or descend into map attributes inside objects.

bash
mkdir -p ~/terraform-labs/terraform-flatten-nested-map/errors/flatten-map-misconception
cd ~/terraform-labs/terraform-flatten-nested-map/errors/flatten-map-misconception
bash
cat > versions.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}
EOF

cat > locals.tf <<'EOF'
locals {
  apps_nested = {
    web = {
      tier = "frontend"
      servers = {
        primary = { hostname = "web01.lab.local", port = 80 }
      }
    }
    api = {
      tier = "backend"
      servers = {
        primary = { hostname = "api01.lab.local", port = 3000 }
      }
    }
  }

  list_wrapping_map = [local.apps_nested]
}
EOF

After init in that directory, console shows what flatten actually does to a list that contains a map:

bash
terraform init -input=false

Flatten the one-element list that wraps the apps map:

bash
printf '%s\n' 'flatten(local.list_wrapping_map)' | terraform console -no-color
output
[
  {
    "api" = { ... }
    "web" = { ... }
  },
]

One outer list level disappeared; the map inside is unchanged. Passing the raw map to flatten fails outright:

bash
printf '%s\n' 'flatten(local.apps_nested)' | terraform console -no-color
output
Error: Error in function call

Call to function "flatten" failed: can only flatten lists, sets and tuples.

Use nested for expressions to extract objects from maps first, then flatten the resulting lists.

Attributes lost during transformation

If a nested for body omits a field you need later, it is gone after flatten — there is no automatic merge. Carry every attribute you reference in each.value into the inner object literal. The main lab keeps app_tier, hostname, and port alongside names for that reason.

Key based on unstable or computed values

Keys must be known at plan time and should not change when unrelated input changes. Composite keys from business identifiers (${app_name}/${srv_name}) work well. Keys from list index or from attributes marked (known after apply) break instance identity or block planning entirely.

Symptom Likely cause Fix
Duplicate object key in locals for Two flattened rows share the same key string Add a distinguishing segment to the key or deduplicate input
Invalid for_each argument + tuple/list Flattened list passed directly to for_each Build servers_by_key map with a for expression first
flatten failed: can only flatten lists Raw map passed to flatten Use nested for to emit lists, then flatten
Removing one item recreates others Keys tied to list index Switch to stable string keys from business identifiers
Plan blocked: unknown for_each keys Key uses computed resource attribute Use static keys; put computed values in each.value only

References


Summary

Nested module inputs often arrive as maps within maps. Nested for expressions walk that structure and produce a list of lists — one inner list per outer key. flatten() recursively collapses those directly nested lists into a single sequence of objects ready for a keyed-map conversion.

The keyed map is the step people skip. for_each needs map keys or set members, not a flat list. A composite key such as ${app_name}/${srv_name} gives readable state addresses and stable identity when you add or remove servers.

Run the transformation in console (local.server_matrix, local.servers_flat, keys(local.servers_by_key)) before you apply. Validate catches syntax errors, but duplicate keys and wrong for_each types usually surface at plan. When a nested collection fix is part of a broader for_each failure, see Invalid for_each argument fixes for related patterns.


Frequently Asked Questions

1. What does Terraform flatten do?

flatten takes a sequence of nested list-like values and recursively collapses directly nested lists into one flat sequence. It does not traverse lists hidden inside maps or objects, so nested maps usually need for expressions first to expose their leaf objects as lists.

2. Can I pass a flattened list directly to for_each?

No. for_each requires a map or a set of strings. After flatten produces a list of objects, build a map with a for expression and stable string keys, such as app_name/srv_name, before passing the result to for_each.

3. Why do for_each keys need to be stable?

Terraform tracks each instance by its for_each key in state. Stable keys let you add or remove one item without Terraform destroying and recreating unrelated instances. Keys derived from list index or from values known only after apply break that stability.

4. Does flatten work on nested maps?

Not directly. flatten operates on list-like nested sequences. Wrap map data in a for expression to emit objects, collect those into nested lists, flatten the lists, then build your keyed map.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)