| 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.
nested map → nested for → list of lists → flatten → keyed map → for_eachThe full walkthrough lives in ~/terraform-labs/terraform-flatten-nested-map/. Examples use terraform_data so you can plan and apply without cloud credentials.
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:
mkdir -p ~/terraform-labs/terraform-flatten-nested-map
cd ~/terraform-labs/terraform-flatten-nested-mapcat > 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 }
}
}
}
}
EOFTransform 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:
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
}
}
EOFInitialize the lab directory:
terraform init -input=falseInspect the nested shape in terraform console:
printf '%s\n' 'local.server_matrix' | terraform console -no-color[
[
{
"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:
printf '%s\n' 'local.servers_flat' | terraform console -no-color[
{
"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:
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:
printf '%s\n' 'keys(local.servers_by_key)' | terraform console -no-color[
"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:
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
}
}
EOFPlan before apply to see keyed instance addresses:
terraform plan -no-color -input=false# 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:
terraform apply -auto-approve -input=falseApply complete! Resources: 3 added, 0 changed, 0 destroyed.List the keyed addresses Terraform recorded:
terraform state listterraform_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:
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
}
}
}
}
EOFPlan with that file:
terraform plan -no-color -input=false -var-file=variables-key-stability-after-remove.tfvars# 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:
mkdir -p ~/terraform-labs/terraform-flatten-nested-map/errors/duplicate-keys
cd ~/terraform-labs/terraform-flatten-nested-map/errors/duplicate-keyscat > 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
}
}
EOFInitialize that error directory:
terraform init -input=falsePlan surfaces the duplicate before any resource is created:
terraform plan -no-color -input=falseError: 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:
mkdir -p ~/terraform-labs/terraform-flatten-nested-map/errors/list-for-each
cd ~/terraform-labs/terraform-flatten-nested-map/errors/list-for-eachcat > 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
}
}
EOFterraform init -input=falseValidate accepts the HCL syntax, but plan rejects the meta-argument type:
terraform plan -no-color -input=falseError: 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.
mkdir -p ~/terraform-labs/terraform-flatten-nested-map/errors/flatten-map-misconception
cd ~/terraform-labs/terraform-flatten-nested-map/errors/flatten-map-misconceptioncat > 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]
}
EOFAfter init in that directory, console shows what flatten actually does to a list that contains a map:
terraform init -input=falseFlatten the one-element list that wraps the apps map:
printf '%s\n' 'flatten(local.list_wrapping_map)' | terraform console -no-color[
{
"api" = { ... }
"web" = { ... }
},
]One outer list level disappeared; the map inside is unchanged. Passing the raw map to flatten fails outright:
printf '%s\n' 'flatten(local.apps_nested)' | terraform console -no-colorError: 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
- Collection functions — flatten — HashiCorp Terraform language docs
- for_each meta-argument — valid argument types and instance addresses
- For expressions — nested comprehensions over maps
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.

