Terraform count vs. for_each: Choosing the Right Meta-Argument

Infrastructure configuration branching into indexed and keyed cloud resource instances

What You’ll Learn

Terraform meta-arguments control how a resource block becomes one or more resource instances. In this lesson, you will learn how to use count and for_each to provision similarly configured cloud resources from lists, sets, and maps.

  • Use count when instances are best represented by numeric positions.
  • Use for_each when instances have stable keys or meaningful configuration.
  • Understand how Terraform addresses instances and why changing collection structure can cause replacements.
  • Choose a collection type that supports safe, maintainable infrastructure changes.
Ad

The Concept

A normal Terraform resource block creates one instance. A meta-argument changes that behavior so the block can create multiple instances.

count creates instances identified by numeric indexes:

aws_s3_bucket.archive[0], aws_s3_bucket.archive[1], and so on.

for_each creates instances identified by keys:

aws_s3_bucket.logs["application"] and aws_s3_bucket.logs["audit"].

Use count when the instances are interchangeable and a number or simple list is enough. It is also useful for conditionally creating one optional resource with an expression such as count = var.enable_backup ? 1 : 0.

Use for_each when each instance has a meaningful identity. A set of strings works well when every instance shares the same configuration. A map is better when each instance needs different values, such as a different instance type or tag.

The choice affects resource addresses in Terraform state. Removing the first element from a list used with count shifts later indexes. Terraform may then plan replacements even though the remaining items still exist. With for_each, removing one map key normally affects only that key.

Basic Example

This configuration provisions two groups of Amazon S3 buckets. The archive buckets use count because they are interchangeable. The operational buckets use for_each because each bucket has a meaningful name.

The AWS provider requires valid AWS credentials and a region before you run terraform apply. These resources can incur cloud-provider charges, so use a test account and destroy them when finished.

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

variable "aws_region" {
  type        = string
  description = "AWS region where the buckets will be created."
  default     = "us-east-1"
}

variable "project_name" {
  type        = string
  description = "Short name used in bucket names."
  default     = "platform-demo"
}

variable "archive_bucket_count" {
  type        = number
  description = "Number of interchangeable archive buckets."
  default     = 2
}

variable "operational_bucket_names" {
  type        = set(string)
  description = "Meaningful names for operational buckets."
  default     = ["application-logs", "audit-logs"]
}

resource "aws_s3_bucket" "archive" {
  count  = var.archive_bucket_count
  bucket = "${var.project_name}-archive-${count.index + 1}"

  tags = {
    Project = var.project_name
    Purpose = "archive"
  }
}

resource "aws_s3_bucket" "operational" {
  for_each = var.operational_bucket_names
  bucket   = "${var.project_name}-${each.key}"

  tags = {
    Project = var.project_name
    Purpose = each.key
  }
}

output "archive_bucket_ids" {
  value = aws_s3_bucket.archive[*].id
}

output "operational_bucket_ids" {
  value = {
    for bucket_name, bucket in aws_s3_bucket.operational :
    bucket_name => bucket.id
  }
}

Expected Output

After terraform plan, Terraform plans four bucket instances. The exact AWS-generated values and plan formatting vary, but the resource addresses follow this pattern:

aws_s3_bucket.archive[0]
aws_s3_bucket.archive[1]
aws_s3_bucket.operational["application-logs"]
aws_s3_bucket.operational["audit-logs"]

To initialize the provider and inspect the plan, run:

terraform init
terraform plan

How the Code Works

A comparison flowchart begins with choosing how resource instances are identified, then branches to count and for_each. The count branch uses interchangeable instances with numeric index addresses, where removing an earlier list item can shift later instances and cause replacements. The for_each branch uses meaningful stable keys, where removing one key normally affects only that instance. Both branches end with choosing the meta-argument that matches the collection.
Use count for interchangeable or optional instances; use for_each when stable keys and per-instance identity matter.
  • count = var.archive_bucket_count creates one archive bucket for each number from zero through count - 1.
  • count.index is the current numeric index. Adding one makes the human-facing suffix start at 1.
  • for_each = var.operational_bucket_names creates one bucket for every value in the set.
  • Inside a for_each resource, each.key and each.value identify the current instance. For a set of strings, the key and value are the same string.
  • The splat expression aws_s3_bucket.archive[*].id collects the IDs from the indexed instances into a list.
  • The output for expression converts the keyed bucket instances into a map, preserving the operational bucket names.

A set does not guarantee a useful ordering, which is one reason it is appropriate for for_each but generally not for positional logic. If each item has several attributes, use a map of objects instead.

Another Example

In a service platform, different services may need different EC2 instance types. A map of objects gives every service a stable key and keeps its configuration together. An optional CloudWatch log group uses count because it is either present once or absent.

variable "service_instances" {
  type = map(object({
    instance_type = string
    role          = string
  }))

  default = {
    api = {
      instance_type = "t3.micro"
      role          = "api"
    }
    worker = {
      instance_type = "t3.small"
      role          = "background-worker"
    }
  }
}

variable "service_ami_id" {
  type        = string
  description = "A valid Linux AMI ID for the selected AWS region."
}

variable "enable_audit_logs" {
  type        = bool
  description = "Whether to create the shared audit log group."
  default     = true
}

resource "aws_instance" "service" {
  for_each = var.service_instances

  ami           = var.service_ami_id
  instance_type = each.value.instance_type

  tags = {
    Name = "${each.key}-service"
    Role = each.value.role
  }
}

resource "aws_cloudwatch_log_group" "audit" {
  count             = var.enable_audit_logs ? 1 : 0
  name              = "/platform/audit"
  retention_in_days = 30

  tags = {
    Purpose = "audit"
  }
}

output "service_instance_ids" {
  value = {
    for service_name, instance in aws_instance.service :
    service_name => instance.id
  }
}

output "audit_log_group_name" {
  value = var.enable_audit_logs ? aws_cloudwatch_log_group.audit[0].name : null
}

The service instances have addresses such as aws_instance.service["api"]. Changing the worker’s instance type updates that instance without changing the API instance’s address. The log group has address aws_cloudwatch_log_group.audit[0] when enabled.

Common Mistakes

Using count for uniquely named items

If a list is used with count, Terraform identifies items by position. Removing the first item shifts every later item:

  • items[0] becomes the new first item.
  • items[1] becomes the new second item.

For long-lived resources, this can produce unwanted updates or replacements. Prefer a map or set with for_each when names represent identity.

Changing between count and for_each without moving state

Changing a resource from count to for_each changes its addresses. Terraform does not automatically know that resource.example[0] is the same object as resource.example["api"]. For existing infrastructure, plan the migration carefully with state moves, such as Terraform’s moved blocks, rather than making the change blindly.

Using a list directly with for_each

for_each accepts a map or a set of strings, not an arbitrary list. Convert a list to a set when ordering does not matter, or create a map when stable names and per-item values matter:

locals {
  service_names = toset(var.service_name_list)

  service_configs = {
    for service in var.service_configs_list :
    service.name => service
  }
}

Do not use a set when duplicate values are meaningful, because converting to a set removes duplicates.

Try It Yourself

Create a Terraform configuration that provisions three interchangeable S3 buckets with count. Use variables for the AWS region, a name prefix, and the number of buckets. Add a Project tag and output the bucket IDs as a list.

Then change the design to use for_each with a set of environment names such as development, staging, and production. Compare the resource addresses in the plan.

Challenge

Design storage for a small application using both meta-arguments:

  • Create one S3 bucket for each entry in a map of named application data buckets.
  • Each map value must provide a purpose tag.
  • Create one optional backup bucket when enable_backup is true, and no backup bucket otherwise.

Use the AWS provider and variables for the region, project name, bucket configuration, and backup flag. Do not use count for the named application data buckets.

Solution

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

variable "aws_region" {
  type    = string
  default = "us-east-1"
}

variable "project_name" {
  type    = string
  default = "inventory-demo"
}

variable "data_buckets" {
  type = map(object({
    purpose = string
  }))

  default = {
    uploads = {
      purpose = "user uploads"
    }
    reports = {
      purpose = "generated reports"
    }
  }
}

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

resource "aws_s3_bucket" "data" {
  for_each = var.data_buckets
  bucket   = "${var.project_name}-${each.key}"

  tags = {
    Project = var.project_name
    Purpose = each.value.purpose
    Type    = "data"
  }
}

resource "aws_s3_bucket" "backup" {
  count  = var.enable_backup ? 1 : 0
  bucket = "${var.project_name}-backup"

  tags = {
    Project = var.project_name
    Purpose = "backup"
    Type    = "backup"
  }
}

output "data_bucket_ids" {
  value = {
    for bucket_name, bucket in aws_s3_bucket.data :
    bucket_name => bucket.id
  }
}

output "backup_bucket_id" {
  value = var.enable_backup ? aws_s3_bucket.backup[0].id : null
}

The map keys, such as uploads and reports, become stable instance addresses. The backup resource uses count only for its optional one-or-zero behavior, so the expression does not create a positional collection of named application buckets.

Key Takeaways

  • Use count for interchangeable or optional instances identified by numeric indexes.
  • Use for_each for instances with stable keys or different per-instance configuration.
  • Maps provide both identity and configuration; sets provide unique string identities without meaningful ordering.
  • Changing list positions can cause address shifts, while removing a for_each key usually affects only that key.
  • Changing a resource’s meta-argument changes its state addresses, so existing resources may require a deliberate state migration.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top
Ad
Ad
Ad