Terraform Module Outputs with count 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 Reading Terraform module outputs when the module block uses count or for_each — single-instance references, count list shape with index and splat, conditional count 0/1 with one(), for_each keyed access, collecting outputs with for expressions, passing outputs between modules, and common unsupported attribute and invalid index errors. Assumes child module outputs are already declared; does not cover writing child output blocks or root output CLI depth.
Related guides Module inputs and outputs
count vs for_each
Terraform modules
Unsupported attribute error
Invalid index error

A single module block exposes outputs as module.NAME.OUTPUT. Add count or for_each on that block and the type of module.NAME changes — which means the dot path you use in the parent must change too.

text
No meta-arg     → module.web.instance_id
count           → module.replica[0].instance_id  or  module.replica[*].instance_id
for_each        → module.svc["web"].instance_id

The module inputs and outputs lesson covers how to declare child outputs and wire module.application.application_name. This lesson focuses on what happens when the parent repeats the same child module with count or for_each.

Each scenario uses its own directory under ~/terraform-labs/terraform-module-output-count-for-each/. Create the shared child modules once, then build each demo directory before you run terraform init.

NOTE
Run terraform init in each new directory. Examples use terraform_data only — no cloud provider required.

Shared lab setup

Create the reusable service module and a small consumer module used later in the chain demo.

bash
mkdir -p ~/terraform-labs/terraform-module-output-count-for-each/modules/service
mkdir -p ~/terraform-labs/terraform-module-output-count-for-each/modules/consumer
cd ~/terraform-labs/terraform-module-output-count-for-each

Write the service child module with instance_id and endpoint outputs:

bash
cat > modules/service/main.tf <<'EOF'
variable "name" {
  type = string
}

resource "terraform_data" "this" {
  input = var.name
}

output "instance_id" {
  value = "svc-${var.name}"
}

output "endpoint" {
  value = "${var.name}.example"
}
EOF

Write the consumer module that accepts a producer target_id:

bash
cat > modules/consumer/main.tf <<'EOF'
variable "target_id" {
  type = string
}

resource "terraform_data" "consumer" {
  input = var.target_id
}

output "consumed_id" {
  value = var.target_id
}
EOF

Every demo below references ../../modules/service (or ../../../modules/service from nested directories). The chain demo also uses ../../modules/consumer.


Normal module output reference

With one module instance and no count or for_each, module.NAME is a single object. Read any child output with dot notation.

bash
mkdir -p ~/terraform-labs/terraform-module-output-count-for-each/demos/single-module
cd ~/terraform-labs/terraform-module-output-count-for-each/demos/single-module

Create the root configuration that calls the service module once:

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

module "web" {
  source = "../../modules/service"
  name   = "web"
}

output "web_instance_id" {
  value = module.web.instance_id
}
EOF

Initialize the single-module demo:

bash
terraform init -input=false

Plan shows the output resolves at plan time because the child output is a known string:

bash
terraform plan -no-color -input=false
output
+ module.web.terraform_data.this will be created

Changes to Outputs:
  + web_instance_id = "svc-web"

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

module.web.instance_id reaches into the child module's output "instance_id" block — the same pattern whether the child holds one resource or many.


How count changes module shape

count on a module block turns module.NAME into a list of module objects, one per index. State addresses use module.replica[0], module.replica[1], and so on.

bash
mkdir -p ~/terraform-labs/terraform-module-output-count-for-each/demos/count-two
cd ~/terraform-labs/terraform-module-output-count-for-each/demos/count-two

Create a root configuration with count = 2 and three output access patterns:

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

module "replica" {
  source = "../../modules/service"
  count  = 2
  name   = "replica-${count.index}"
}

output "first_instance_id" {
  value = module.replica[0].instance_id
}

output "all_instance_ids" {
  value = module.replica[*].instance_id
}

output "index_to_instance_id" {
  value = { for i, m in module.replica : i => m.instance_id }
}
EOF

Initialize the count demo:

bash
terraform init -input=false

Plan creates two module instances and previews all three output shapes:

bash
terraform plan -no-color -input=false
output
+ module.replica[0].terraform_data.this will be created
  + module.replica[1].terraform_data.this will be created

Changes to Outputs:
  + all_instance_ids     = ["svc-replica-0", "svc-replica-1"]
  + first_instance_id    = "svc-replica-0"
  + index_to_instance_id = { "0" = "svc-replica-0", "1" = "svc-replica-1" }

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

module.replica[0] picks one instance. module.replica[*].instance_id splats every instance into a list. The for expression builds a map when you need index keys preserved — useful before passing data to another keyed structure.


Safely read a conditional count module

Feature flags often use count = var.enable ? 1 : 0. When count is zero, module.monitor is an empty list — indexing [0] fails.

bash
mkdir -p ~/terraform-labs/terraform-module-output-count-for-each/demos/count-conditional
cd ~/terraform-labs/terraform-module-output-count-for-each/demos/count-conditional

Create the conditional count root with one() on the splat:

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

variable "enable" {
  type    = bool
  default = true
}

module "monitor" {
  source = "../../modules/service"
  count  = var.enable ? 1 : 0
  name   = "monitor"
}

output "monitor_instance_id" {
  value = one(module.monitor[*].instance_id)
}
EOF

The splat module.monitor[*].instance_id returns a list with zero or one element. one() extracts the single value when present and returns null when the list is empty.

Initialize the conditional demo with the default enable = true:

bash
terraform init -input=false

Plan shows one module instance and a concrete output value:

bash
terraform plan -no-color -input=false
output
+ module.monitor[0].terraform_data.this will be created

Changes to Outputs:
  + monitor_instance_id = "svc-monitor"

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

Switch to the count-zero variant where enable defaults to false:

bash
mkdir -p ~/terraform-labs/terraform-module-output-count-for-each/demos/count-conditional/count-zero
cd ~/terraform-labs/terraform-module-output-count-for-each/demos/count-conditional/count-zero

Copy the same pattern but default enable to false:

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

variable "enable" {
  type    = bool
  default = false
}

module "monitor" {
  source = "../../../modules/service"
  count  = var.enable ? 1 : 0
  name   = "monitor"
}

output "monitor_instance_id" {
  value = one(module.monitor[*].instance_id)
}

output "all_monitor_ids_splat" {
  value = module.monitor[*].instance_id
}
EOF

Initialize and plan with count at zero:

bash
terraform init -input=false

Plan creates no module instances — only output values:

bash
terraform plan -no-color -input=false
output
Changes to Outputs:
  + all_monitor_ids_splat = []

You can apply this plan to save these new output values to the Terraform
state, without changing any real infrastructure.

The splat is an empty list. one(module.monitor[*].instance_id) evaluates to null in the console — safe for optional wiring into another argument that accepts null, unlike module.monitor[0].instance_id which errors on an empty tuple.


How for_each changes module shape

for_each on a module block turns module.NAME into a map keyed by instance key. Bracket syntax uses the for_each key, not a numeric index.

bash
mkdir -p ~/terraform-labs/terraform-module-output-count-for-each/demos/foreach-map
cd ~/terraform-labs/terraform-module-output-count-for-each/demos/foreach-map

Create a for_each module root and read one keyed output:

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

locals {
  services = {
    web = "nginx"
    api = "orders"
  }
}

module "svc" {
  source   = "../../modules/service"
  for_each = local.services
  name     = each.value
}

output "web_instance_id" {
  value = module.svc["web"].instance_id
}
EOF

Initialize the for_each demo:

bash
terraform init -input=false

Plan shows keyed module addresses and the selected web output:

bash
terraform plan -no-color -input=false
output
+ module.svc["api"].terraform_data.this will be created
  + module.svc["web"].terraform_data.this will be created

Changes to Outputs:
  + web_instance_id = "svc-nginx"

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

module.svc["web"] selects the instance whose for_each key is "web". The child's name argument came from each.value ("nginx"), which is why instance_id is svc-nginx — the output key and the service name are different concepts.


Collect all module outputs

When you need every instance's output as a list or map, iterate the module object directly. A for_each module is already a map from key to module instance.

bash
mkdir -p ~/terraform-labs/terraform-module-output-count-for-each/demos/aggregate-outputs
cd ~/terraform-labs/terraform-module-output-count-for-each/demos/aggregate-outputs

Create a for_each root with list and map aggregation outputs:

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

locals {
  services = {
    web  = "web"
    api  = "api"
    jobs = "jobs"
  }
}

module "svc" {
  source   = "../../modules/service"
  for_each = local.services
  name     = each.value
}

output "instance_id_list" {
  value = [for k, m in module.svc : m.instance_id]
}

output "instance_id_map" {
  value = { for k, m in module.svc : k => m.instance_id }
}

output "endpoints_by_key" {
  value = { for k, m in module.svc : k => m.endpoint }
}
EOF

Initialize the aggregate demo:

bash
terraform init -input=false

Apply and read the collected outputs:

bash
terraform apply -auto-approve -input=false -no-color

After apply completes, print the aggregated root outputs:

bash
terraform output -no-color
output
instance_id_list = [
  "svc-api",
  "svc-jobs",
  "svc-web",
]
instance_id_map = {
  "api"  = "svc-api"
  "jobs" = "svc-jobs"
  "web"  = "svc-web"
}

The list form drops keys; the map form keeps for_each keys attached to each value. Use the map when downstream modules must address the same keys.


Pass one module output to another

When a consumer module repeats with the same keys as a producer module, set for_each = module.svc and read outputs from the producer inside the block.

bash
mkdir -p ~/terraform-labs/terraform-module-output-count-for-each/demos/chain-modules
cd ~/terraform-labs/terraform-module-output-count-for-each/demos/chain-modules

Create a chain root that passes producer outputs into a consumer module:

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

locals {
  services = {
    web = "web"
    api = "api"
  }
}

module "svc" {
  source   = "../../modules/service"
  for_each = local.services
  name     = each.value
}

module "consumer" {
  source    = "../../modules/consumer"
  for_each  = module.svc
  target_id = module.svc[each.key].instance_id
}

output "consumed_by_key" {
  value = { for k, m in module.consumer : k => m.consumed_id }
}
EOF

for_each = module.svc gives the consumer module the same instance keys as the producer. module.svc[each.key].instance_id passes the matching output into each consumer instance.

Initialize the chain demo:

bash
terraform init -input=false

Plan wires each consumer to its producer output:

bash
terraform plan -no-color -input=false
output
+ module.consumer["api"].terraform_data.consumer will be created
  + module.consumer["web"].terraform_data.consumer will be created
  + module.svc["api"].terraform_data.this will be created
  + module.svc["web"].terraform_data.this will be created

Changes to Outputs:
  + consumed_by_key = {
      + "api" = "svc-api"
      + "web" = "svc-web"
    }

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

Terraform creates an implicit dependency from consumer to producer because the consumer argument references module.svc[each.key].instance_id.


Module output shape quick reference

Module block Type of module.NAME Read one output Read all outputs
Single instance Object module.web.instance_id N/A (one instance)
count = N List of objects module.replica[0].instance_id module.replica[*].instance_id
count = 0 or 1 List (maybe empty) one(module.monitor[*].instance_id) module.monitor[*].instance_id
for_each Map of objects module.svc["web"].instance_id { for k, m in module.svc : k => m.instance_id }

Common module output errors

Symptom Likely cause Fix
Unsupported attribute on module.NAME.output Module uses countmodule.NAME is a list Use module.NAME[0].output or splat
Unsupported attribute — object has attribute "web" Module uses for_each — you addressed an output name as if it were a key Use module.NAME["web"].output
Invalid index — empty tuple count = 0 and you used [0] Use one(module.NAME[*].output) or guard with length()
Invalid index — key not in object Wrong for_each key such as ["missing"] Match the key from your for_each map or toset
Treating map as list for_each module indexed with [0] Use string keys: module.NAME["key"]

Unsupported attribute on a counted module

errors/wrong-count-shape/ uses count = 1 but references module.app.instance_id:

bash
mkdir -p ~/terraform-labs/terraform-module-output-count-for-each/errors/wrong-count-shape
cd ~/terraform-labs/terraform-module-output-count-for-each/errors/wrong-count-shape

Create a counted module root that omits the list index:

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

module "app" {
  source = "../../modules/service"
  count  = 1
  name   = "web"
}

output "bad" {
  value = module.app.instance_id
}
EOF

Initialize and validate:

bash
terraform init -input=false && terraform validate -no-color
output
Error: Unsupported attribute

  on main.tf line 12, in output "bad":
  12:   value = module.app.instance_id
    ├────────────────
    │ module.app is a list of object

Can't access attributes on a list of objects. Did you mean to access
attribute "instance_id" for a specific element of the list, or across all
elements of the list?

Terraform tells you module.app is a list — add [0] or [*].

Unsupported attribute on a for_each module

errors/wrong-foreach-shape/ uses for_each but omits the instance key:

bash
mkdir -p ~/terraform-labs/terraform-module-output-count-for-each/errors/wrong-foreach-shape
cd ~/terraform-labs/terraform-module-output-count-for-each/errors/wrong-foreach-shape

Create a for_each module root that omits the instance key:

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

module "app" {
  source   = "../../modules/service"
  for_each = toset(["a"])
  name     = each.key
}

output "bad" {
  value = module.app.instance_id
}
EOF

Initialize and plan:

bash
terraform init -input=false && terraform plan -no-color -input=false
output
Error: Unsupported attribute

  on main.tf line 12, in output "bad":
  12:   value = module.app.instance_id
    ├────────────────
    │ module.app is object with 1 attribute "a"

This object does not have an attribute named "instance_id".

module.app is a map whose top-level attributes are instance keys ("a"), not output names. Use module.app["a"].instance_id.

Invalid index when count is zero

errors/count-zero-index/ sets count = 0 and indexes [0]:

bash
mkdir -p ~/terraform-labs/terraform-module-output-count-for-each/errors/count-zero-index
cd ~/terraform-labs/terraform-module-output-count-for-each/errors/count-zero-index

Create a zero-count root that indexes [0]:

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

module "app" {
  source = "../../modules/service"
  count  = 0
  name   = "web"
}

output "bad" {
  value = module.app[0].instance_id
}
EOF

Initialize and plan:

bash
terraform init -input=false && terraform plan -no-color -input=false
output
Error: Invalid index

  on main.tf line 12, in output "bad":
  12:   value = module.app[0].instance_id
    ├────────────────
    │ module.app is empty tuple

The given key does not identify an element in this collection value: the
collection has no elements.

An empty counted module is a zero-length list — no [0] element exists.

Key mismatch on a for_each module

errors/key-mismatch/ references a key that was never created:

bash
mkdir -p ~/terraform-labs/terraform-module-output-count-for-each/errors/key-mismatch
cd ~/terraform-labs/terraform-module-output-count-for-each/errors/key-mismatch

Create a for_each root that references a nonexistent key:

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

module "app" {
  source   = "../../modules/service"
  for_each = toset(["a"])
  name     = each.key
}

output "bad" {
  value = module.app["missing"].instance_id
}
EOF

Initialize and plan:

bash
terraform init -input=false && terraform plan -no-color -input=false
output
Error: Invalid index

  on main.tf line 12, in output "bad":
  12:   value = module.app["missing"].instance_id
    ├────────────────
    │ module.app is object with 1 attribute "a"

The given key does not identify an element in this collection value.

Only "a" exists in this configuration — verify keys with keys(module.app) in terraform console when wiring unfamiliar modules.


References


Summary

You started with the simple case — module.web.instance_id when the module block has no meta-arguments — then walked through how count turns the module into a list and for_each turns it into a keyed map. Indexed access, splats, and for expressions are the three tools you reach for when you need one value, every value, or a reshaped collection.

Conditional count = var.enable ? 1 : 0 is the case that breaks [0] indexing. The splat-plus-one() pattern returns null when no instance exists instead of failing plan. Chaining modules is straightforward when both sides share the same for_each keys — set for_each = module.producer on the consumer and pass module.producer[each.key].output_name into its arguments.

When Terraform reports Unsupported attribute or Invalid index on a module reference, check the module block first: list shape needs brackets, map shape needs string keys, and an empty counted module has no zeroth element. Deeper troubleshooting patterns live in the Unsupported attribute and Invalid index guides.


Frequently Asked Questions

1. How do I read an output from a module that uses count?

A counted module becomes a list of module objects. Use bracket indexing for one instance, such as module.replica[0].instance_id, or a splat for every instance, such as module.replica[*].instance_id. Do not use module.replica.instance_id without an index.

2. How do I read an output from a module that uses for_each?

A for_each module becomes a map keyed by instance key. Use string keys in brackets, such as module.svc["web"].instance_id. The key is each.key from the module block, not an output name on the module object.

3. What is the safe way to read output when count might be zero?

Use a splat plus one(), such as one(module.monitor[*].instance_id), which returns the single value when count is 1 and null when count is 0. Avoid module.monitor[0] unless you know count is always at least 1.

4. Can I loop over module outputs to build a map?

Yes. When module.svc uses for_each, iterate module.svc with a for expression: { for k, m in module.svc : k => m.instance_id }. The iterator value m is the whole module instance object with all its outputs.
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)