Terraform Lesson 22 – Count and for_each | Dataplexa
Section II · Lesson 22

Count and for_each

You have used both. But the edge cases are where teams get burned — removing an item from the middle of a count list, converting between count and for_each without destroy, using for_each with complex objects, and the for expressions that transform data before feeding it into these meta-arguments. This lesson covers all of it with real consequences demonstrated.

This lesson covers

count edge cases and index instability → for_each with maps, sets, and complex objects → Converting count to for_each safely → for expressions for data transformation → Conditional resource creation

Setting Up

Create a project that explores all aspects of count and for_each with real AWS resources.

mkdir terraform-lesson-22
cd terraform-lesson-22
touch versions.tf variables.tf main.tf outputs.tf .gitignore

Add this to versions.tf:

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

Run terraform init and continue.

count — Index Instability Demonstrated

count creates multiple instances identified by their index position — 0, 1, 2. The problem: when you remove an item from the middle of the list driving count, every index above the removed item shifts down. What was index 2 becomes index 1. Terraform sees this as modifying index 1 and destroying index 2 — even though you only wanted to remove one item.

We are going to demonstrate this live — create three S3 buckets with count, remove the middle one, and watch what happens.

New terms:

  • count.index — the zero-based index of the current resource instance. Inside a resource with count = 3, count.index is 0 for the first, 1 for the second, 2 for the third.
  • element() function — retrieves an element from a list by index. element(["a","b","c"], count.index) returns the element at position count.index. Safer than direct indexing because it wraps around rather than throwing an out-of-bounds error.
  • index instability — the behaviour where removing a list item causes all subsequent items to shift to lower indexes, triggering unintended modifications or replacements on resources that should be untouched.

Add this to variables.tf:

variable "bucket_names" {
  description = "List of S3 bucket name suffixes to create"
  type        = list(string)
  default     = ["alpha", "beta", "gamma"]  # Three buckets — we'll remove beta to demo instability
}

variable "environment" {
  description = "Deployment environment"
  type        = string
  default     = "dev"
}

Add this to main.tf:

# Three buckets created using count — identified by index position
resource "aws_s3_bucket" "count_demo" {
  count = length(var.bucket_names)  # Creates one bucket per item in the list

  # element() retrieves the name at the current index
  bucket = "lesson22-count-${element(var.bucket_names, count.index)}-${var.environment}"

  tags = {
    Name      = "lesson22-count-${element(var.bucket_names, count.index)}"
    Index     = count.index    # Track which index owns this bucket
    ManagedBy = "Terraform"
  }
}
# Apply with all three buckets
terraform apply
$ terraform apply

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

  Enter a value: yes

aws_s3_bucket.count_demo[0]: Creation complete — lesson22-count-alpha-dev
aws_s3_bucket.count_demo[1]: Creation complete — lesson22-count-beta-dev
aws_s3_bucket.count_demo[2]: Creation complete — lesson22-count-gamma-dev

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

# Now remove "beta" from the middle of the list
# Change default to: ["alpha", "gamma"]
# Then run plan — watch what happens to gamma

$ terraform plan

  # aws_s3_bucket.count_demo[1] will be updated in-place
  ~ resource "aws_s3_bucket" "count_demo" {
      # Index 1 now = gamma (was beta) — name changes from beta to gamma
      ~ bucket = "lesson22-count-beta-dev" -> "lesson22-count-gamma-dev"
    }

  # aws_s3_bucket.count_demo[2] will be destroyed
  - resource "aws_s3_bucket" "count_demo" {
      # Index 2 no longer exists — gamma shifted to index 1
      - bucket = "lesson22-count-gamma-dev"
    }

Plan: 0 to add, 1 to change, 1 to destroy.
# We wanted to remove beta and keep gamma unchanged
# Instead: gamma was modified AND an extra bucket was destroyed
# This is index instability — the fundamental problem with count

What just happened?

  • Removing "beta" from position 1 caused "gamma" to shift from index 2 to index 1. Terraform does not understand the concept of "removing the middle item." It sees index 1 in the old state mapped to "beta" and index 1 in the new configuration mapped to "gamma". It plans to rename bucket at index 1 and destroy the now-absent index 2. The gamma bucket — which we intended to keep unchanged — gets modified.
  • This is the defining limitation of count. Count is safe when the list only grows (append to the end) or shrinks from the end. The moment you remove from the middle or reorder, index instability causes unintended changes. For any resource set that might have items removed from the middle — use for_each with stable string keys instead.

for_each with Maps and Sets

for_each solves index instability by identifying instances with stable string keys. Remove "beta" from a map and only the "beta" resource is affected. "alpha" and "gamma" are untouched.

New terms:

  • toset() function — converts a list to a set, removing duplicates and stripping ordering. Required when passing a list variable to for_each — for_each does not accept lists directly, only maps and sets.
  • each.key — the current map key or set value. Inside for_each = toset(["alpha","beta","gamma"]), each.key and each.value are both the current string — for sets there is no separate key and value.
  • each.value — for maps, the value associated with each.key. For a map like { alpha = "t2.micro", gamma = "t3.small" }, each.key is "alpha" and each.value is "t2.micro" for the first iteration.

Replace the count_demo resource with a for_each version. Also update variables.tf:

# Updated variables.tf — map instead of list
variable "buckets" {
  description = "Map of bucket key to configuration object"
  type = map(object({
    suffix      = string  # Name suffix for the bucket
    environment = string  # Environment tag value
  }))
  default = {
    alpha = { suffix = "alpha", environment = "dev"  }
    beta  = { suffix = "beta",  environment = "dev"  }
    gamma = { suffix = "gamma", environment = "prod" }  # Gamma is prod, others are dev
  }
}
# main.tf — for_each with a map of objects
resource "aws_s3_bucket" "foreach_demo" {
  for_each = var.buckets  # Map drives for_each — each.key is "alpha", "beta", or "gamma"

  bucket = "lesson22-foreach-${each.value.suffix}-${each.value.environment}"

  tags = {
    Name        = "lesson22-foreach-${each.key}"    # Key used as stable identifier
    Environment = each.value.environment             # Value field from the object
    ManagedBy   = "Terraform"
  }
}
terraform apply

# Now remove beta — update variables.tf to remove the beta entry
# default = {
#   alpha = { suffix = "alpha", environment = "dev"  }
#   gamma = { suffix = "gamma", environment = "prod" }
# }

terraform plan
$ terraform apply  # Initial deploy

aws_s3_bucket.foreach_demo["alpha"]: Creation complete — lesson22-foreach-alpha-dev
aws_s3_bucket.foreach_demo["beta"]:  Creation complete — lesson22-foreach-beta-dev
aws_s3_bucket.foreach_demo["gamma"]: Creation complete — lesson22-foreach-gamma-prod

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

# Remove "beta" from the map — only beta is affected
$ terraform plan

  # aws_s3_bucket.foreach_demo["beta"] will be destroyed
  - resource "aws_s3_bucket" "foreach_demo" {
      - bucket = "lesson22-foreach-beta-dev"
    }

Plan: 0 to add, 0 to change, 1 to destroy.
# Only beta is destroyed — alpha and gamma are completely untouched
# This is the key advantage of for_each over count

What just happened?

  • Only beta was destroyed — alpha and gamma were completely unaffected. Terraform identifies resources by their map key — "alpha", "beta", "gamma". Removing "beta" from the map removes exactly one entry. The keys "alpha" and "gamma" still exist in the map and still match their state entries. Zero unintended changes.
  • Each bucket got its own configuration from each.value. The gamma bucket used each.value.environment = "prod" while alpha and beta used "dev". The map of objects pattern lets each instance have completely different configuration — not just a name difference.
  • The bucket addresses use the key in brackets. aws_s3_bucket.foreach_demo["alpha"] is how you reference specific instances in outputs, state commands, and other resource references. The key is always a string — quoted in bracket notation.

Converting count to for_each Safely

You have existing infrastructure managed with count and want to switch to for_each. A naive conversion — delete the count block, add for_each — would destroy all count-indexed resources and recreate them as for_each-keyed resources. That means downtime and data loss for stateful resources.

The safe path uses moved blocks from Lesson 18. Move each count-indexed resource to its new for_each-keyed address before changing the configuration.

# moves.tf — safe conversion from count to for_each
# Step 1: add these moved blocks BEFORE changing the resource block

# Map each count index to its corresponding for_each key
moved {
  from = aws_s3_bucket.count_demo[0]      # Old count-indexed address
  to   = aws_s3_bucket.foreach_demo["alpha"]  # New for_each-keyed address
}

moved {
  from = aws_s3_bucket.count_demo[1]
  to   = aws_s3_bucket.foreach_demo["beta"]
}

moved {
  from = aws_s3_bucket.count_demo[2]
  to   = aws_s3_bucket.foreach_demo["gamma"]
}

# Step 2: update the resource block — remove count, add for_each
# Step 3: terraform plan — should show only moves, zero destroys
# Step 4: terraform apply
# Step 5: remove the moved blocks after successful apply
$ terraform plan

  # aws_s3_bucket.count_demo[0] has moved to aws_s3_bucket.foreach_demo["alpha"]
  resource "aws_s3_bucket" "foreach_demo" {
      id = "lesson22-count-alpha-dev"
      # (no attribute changes)
  }

  # aws_s3_bucket.count_demo[1] has moved to aws_s3_bucket.foreach_demo["beta"]
  resource "aws_s3_bucket" "foreach_demo" {
      id = "lesson22-count-beta-dev"
  }

  # aws_s3_bucket.count_demo[2] has moved to aws_s3_bucket.foreach_demo["gamma"]
  resource "aws_s3_bucket" "foreach_demo" {
      id = "lesson22-count-gamma-dev"
  }

Plan: 0 to add, 0 to change, 0 to destroy.
# Zero destroys — three safe moves — all real resources untouched

What just happened?

  • Three state moves completed with zero destroys. The three count-indexed resources were renamed to for_each-keyed addresses entirely in state. The real S3 buckets were never touched — no API calls to AWS, no downtime, no data loss. The same bucket that was aws_s3_bucket.count_demo[0] is now aws_s3_bucket.foreach_demo["alpha"].
  • moved blocks documented the migration in source control. The conversion is visible in a PR, reviewable before merge, and provides a permanent record of what moved where. A teammate who has an older copy of state will have the moves applied automatically on their next apply.

for Expressions — Transforming Data for for_each

for expressions transform lists and maps into new collections. They are the bridge between the data you have — a list of names, a map with the wrong structure — and the format for_each needs. Understanding for expressions is what unlocks truly flexible configurations.

New terms:

  • for expression producing a list[for item in list : transformation]. Square brackets produce a list. The item variable holds the current element. The transformation is any expression using item.
  • for expression producing a map{ for item in list : key_expression => value_expression }. Curly braces produce a map. The => separates the key from the value. Used to build a map from a list for use with for_each.
  • if clause in for expressionsfor item in list : item if condition. Filters items from the collection. Only items where the condition is true appear in the output. Equivalent to a WHERE clause.
  • keys() and values() functionskeys(map) returns a sorted list of all map keys. values(map) returns the map values as a list in key-sorted order. Used in outputs when you want just the keys or just the values from a for_each result.
# locals.tf — for expressions demonstrated in locals (best practice for complex transforms)

locals {
  # Transform a simple list into a map for use with for_each
  # Input: ["alpha", "beta", "gamma"]
  # Output: { "alpha" = "alpha", "beta" = "beta", "gamma" = "gamma" }
  bucket_map = { for name in var.bucket_names : name => name }

  # Filter the map to only include prod-environment buckets
  # Input: { alpha = {...dev}, beta = {...dev}, gamma = {...prod} }
  # Output: { gamma = {...prod} }
  prod_buckets = {
    for key, config in var.buckets : key => config
    if config.environment == "prod"  # Only include entries where environment is prod
  }

  # Build a flat list of all bucket names from the foreach_demo resource
  # Converts the map result (keyed by "alpha", "beta", etc.) into a plain list of names
  all_bucket_names = [
    for key, bucket in aws_s3_bucket.foreach_demo : bucket.bucket
  ]

  # Transform a list of names to uppercase — demonstrates list for expression
  bucket_names_upper = [
    for name in var.bucket_names : upper(name)  # upper() converts string to uppercase
  ]

  # Merge environment-specific tags with common tags for each bucket
  # Creates a map of key => tags_map
  bucket_tags = {
    for key, config in var.buckets : key => merge(
      {
        Name      = "lesson22-${key}"
        ManagedBy = "Terraform"
      },
      { Environment = config.environment }  # Each bucket gets its own environment tag
    )
  }
}
# Explore the for expressions in terraform console
terraform console
$ terraform console

# List to map conversion
> local.bucket_map
{
  "alpha" = "alpha"
  "beta"  = "beta"
  "gamma" = "gamma"
}

# Filter to prod buckets only
> local.prod_buckets
{
  "gamma" = {
    environment = "prod"
    suffix      = "gamma"
  }
}

# Uppercase list transformation
> local.bucket_names_upper
tolist([
  "ALPHA",
  "BETA",
  "GAMMA",
])

# Per-bucket merged tags
> local.bucket_tags
{
  "alpha" = {
    "Environment" = "dev"
    "ManagedBy"   = "Terraform"
    "Name"        = "lesson22-alpha"
  }
  "beta" = {
    "Environment" = "dev"
    "ManagedBy"   = "Terraform"
    "Name"        = "lesson22-beta"
  }
  "gamma" = {
    "Environment" = "prod"
    "ManagedBy"   = "Terraform"
    "Name"        = "lesson22-gamma"
  }
}

What just happened?

  • local.bucket_map converted a list to a map for for_each. The for expression { for name in var.bucket_names : name => name } iterates the list and creates a map where both key and value are the item. This is the standard pattern for using a list variable with for_each without changing the variable type.
  • local.prod_buckets filtered the map with an if clause. Only entries where config.environment == "prod" passed through — leaving only gamma. This pattern is used to create environment-specific subsets of resources from a single source of truth.
  • local.bucket_tags built per-resource tags without repeating the merge() call in every resource block. The tags for each bucket — including its environment-specific tag — are computed once in locals and referenced in the resource blocks. Change the common tags in one place and every resource updates.

Conditional Resource Creation

Sometimes a resource should only exist in certain environments. A CloudWatch alarm belongs in prod but not dev. An enhanced monitoring configuration belongs in prod but adds unnecessary cost in dev. Terraform handles this with two patterns: count = 0 or 1, and for_each with an empty or populated map.

New terms:

  • count = condition ? 1 : 0 — the standard pattern for conditional resource creation with count. When the condition is true, one instance is created. When false, zero instances are created and the resource does not exist. The ternary operator condition ? true_value : false_value evaluates to one of two values based on the condition.
  • for_each with empty map/set — passing an empty map {} or empty set toset([]) to for_each creates zero instances. This is the for_each equivalent of count = 0. Preferred over count for conditional creation because it avoids the single-element index access pattern resource.name[0].
  • one() function — extracts the single element from a list or set that contains zero or one elements. one(aws_instance.optional) returns the instance if it exists, or null if count is 0. Cleaner than aws_instance.optional[0] which throws an error when count is 0.
variable "enable_monitoring" {
  description = "Enable enhanced CloudWatch monitoring — prod only, costs extra"
  type        = bool
  default     = false  # Off by default — enable explicitly in prod tfvars
}

variable "environment" {
  description = "Deployment environment"
  type        = string
  default     = "dev"
}

# S3 bucket that always exists
resource "aws_s3_bucket" "app_data" {
  bucket = "lesson22-app-data-${var.environment}"

  tags = {
    Environment = var.environment
    ManagedBy   = "Terraform"
  }
}

# Conditional resource — only exists when enable_monitoring is true
# count = 1 creates it, count = 0 does not
resource "aws_s3_bucket_intelligent_tiering_configuration" "app_data" {
  count = var.enable_monitoring ? 1 : 0  # Ternary — 1 if true, 0 if false

  bucket = aws_s3_bucket.app_data.bucket  # References the always-existing bucket
  name   = "EntireS3Bucket"

  tiering {
    access_tier = "DEEP_ARCHIVE_ACCESS"
    days        = 180  # Move objects to deep archive after 180 days
  }
}

# Conditional resource using for_each pattern — cleaner for complex conditionals
# Local computes an empty or populated map based on conditions
locals {
  # Only create monitoring config for prod environments with monitoring enabled
  monitoring_config = var.environment == "prod" && var.enable_monitoring ? {
    "primary" = { tier = "DEEP_ARCHIVE_ACCESS", days = 180 }
  } : {}  # Empty map = zero resources created
}

resource "aws_s3_bucket_intelligent_tiering_configuration" "conditional" {
  for_each = local.monitoring_config  # Empty map = no resources

  bucket = aws_s3_bucket.app_data.bucket
  name   = "Conditional-${each.key}"

  tiering {
    access_tier = each.value.tier
    days        = each.value.days
  }
}

# Output that safely handles count = 0 using one()
output "tiering_id" {
  description = "ID of the tiering config if it exists, null if monitoring is disabled"
  value       = one(aws_s3_bucket_intelligent_tiering_configuration.app_data[*].id)
  # [*] splat returns a list — one() extracts single item or returns null
}
# Deploy with monitoring disabled (default)
$ terraform apply

Plan: 1 to add, 0 to change, 0 to destroy.
# Only the bucket — tiering config has count=0, monitoring_config is empty map

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:
tiering_id = null   # one() returned null because count=0 — no error

# Enable monitoring — changes count=0 to count=1 and populates monitoring_config
$ terraform apply -var="enable_monitoring=true" -var="environment=prod"

Plan: 2 to add, 0 to change, 0 to destroy.
# Both the count-based and for_each-based tiering configs will be created

aws_s3_bucket_intelligent_tiering_configuration.app_data[0]: Creating...
aws_s3_bucket_intelligent_tiering_configuration.conditional["primary"]: Creating...

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:
tiering_id = "lesson22-app-data-prod:EntireS3Bucket"   # one() returned the real ID

What just happened?

  • count = 0 created zero resources — the configuration block exists but produces nothing. The S3 bucket tiering config block is in main.tf — Terraform reads it, evaluates count = false ? 1 : 0 = 0, and creates zero instances. On the next apply with monitoring enabled, count = 1 and one instance is created. No resource block needs to be added or removed — just the variable value changes.
  • one() safely handled both count=0 and count=1. When count is 0, aws_s3_bucket_intelligent_tiering_configuration.app_data[*].id returns an empty list. one([]) returns null — no error. When count is 1, it returns the single ID. Using [0] instead would throw "index 0 is out of bounds" when count is 0.
  • The for_each pattern computed a populated or empty map in locals. The ternary expression in local.monitoring_config returns a one-entry map for prod+monitoring or an empty map otherwise. for_each = {} creates zero resources. This is cleaner than count for complex conditions because it avoids the awkward [0] accessor pattern entirely.

Common Mistakes

Passing a list directly to for_each

for_each does not accept lists — only maps and sets. for_each = var.bucket_names where bucket_names is a list(string) throws: "The given value is not suitable for for_each: must be a map or a set." Convert with toset(var.bucket_names) or use a map variable from the start.

Using count for resources that may have items removed from the middle

The index instability demonstrated earlier is not a corner case — it happens every time a team removes any item except the last one from a count-driven list. If the list can grow and shrink in any direction, use for_each. Only use count for fixed-size collections like "always create exactly 2 availability zones" — where the count never changes.

Accessing count=0 resources with [0]

When count is 0, there is no element at index 0 — accessing it with resource.name[0].attribute throws an error. In outputs and other references, always use the splat [*] combined with one() for optional resources: one(resource.name[*].attribute). This returns null when count is 0 and the actual value when count is 1.

The decision rule: count or for_each?

Use count only when the number of instances is a single integer that will not drive naming or differentiation — "create exactly N identical load balancer listeners." Use for_each for everything else: when instances have different names, when the collection may have items removed from the middle, when each instance needs different configuration. If in doubt, use for_each — it is strictly safer and more flexible.

Practice Questions

1. Your variable is list(string) and you want to use it with for_each. Which function converts it to a compatible type?



2. Write the count argument that creates one instance when var.enable_feature is true and zero instances when it is false.



3. Which function safely extracts the value from a list that contains either zero or one elements — returning null when empty instead of throwing an error?



Quiz

1. You have 5 EC2 instances created with count. You remove the item at index 2. What does terraform plan show?


2. How do you convert a resource from count to for_each without destroying and recreating the real infrastructure?


3. You have a list(string) variable and need to pass it to for_each. What is the correct approach?


Up Next · Lesson 23

Dynamic Blocks

You have used a dynamic block for security group rules. Lesson 23 goes deep — nested dynamic blocks, dynamic blocks inside modules, and the patterns that eliminate repetitive nested configuration without losing readability.