count & for_each: Dynamic Resource Creation Done Right

count & for_each: Dynamic Resource Creation Done Right
The Problem: Copy-Pasting Resource Blocks
resource "aws_instance" "web_1" { ... }
resource "aws_instance" "web_2" { ... }
resource "aws_instance" "web_3" { ... }

When you need a fourth, you add another block. When you need two in prod and one in dev, you fork the config. When you need to change the AMI, you update it in three places and miss one. This isn’t infrastructure as code — this is clicking, but slower.

Terraform gives you two meta-arguments for creating multiple resources from a single block: count and for_each. They’re not interchangeable. They have fundamentally different identity models, and picking the wrong one causes real production pain.

count — “Give Me N of These”

count creates N copies of a resource, identified by their numeric index.

resource "aws_instance" "web" {
  count         = 3
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"

  tags = {
    Name = "web-${count.index}"
  }
}

Creates three instances: aws_instance.web[0], aws_instance.web[1], aws_instance.web[2].

count.index gives you the current iteration number (0-based). Common uses: naming resources, selecting from a list, computing CIDR blocks:

variable "azs" {
  default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

resource "aws_subnet" "public" {
  count             = length(var.azs)
  availability_zone = var.azs[count.index]
  cidr_block        = "10.0.${count.index}.0/24"
  vpc_id            = aws_vpc.main.id
}
The count Trap — Why Index-Based Identity Is Fragile

This is where count reveals its fundamental limitation. Terraform identifies each resource by its index. If you modify the list, the indexes shift — and Terraform interprets that as changes to existing resources, not additions or removals.

Start with three availability zones:

default = ["us-east-1a", "us-east-1b", "us-east-1c"]

Resources: [0]=us-east-1a, [1]=us-east-1b, [2]=us-east-1c

Now remove the first element:

default = ["us-east-1b", "us-east-1c"]

New mapping: [0]=us-east-1b, [1]=us-east-1c

Terraform sees:
– [0] changed from us-east-1a to us-east-1b → update or replace
– [1] changed from us-east-1b to us-east-1c → update or replace
– [2] used to exist, now doesn’t → destroy

You wanted to remove one subnet. Terraform wants to modify two and destroy one. For subnets with instances running on them, this cascading replacement is a production disaster.

The lesson: use count only when the resources are truly interchangeable and identical — when their identity doesn’t matter beyond their number. “Give me 3 worker nodes” is a count use case. “Create a subnet in each of these AZs” is not — each subnet has a meaningful identity tied to its AZ, and removing one shouldn’t affect the others.

for_each — “One Resource Per Key”

for_each creates one resource instance per element in a map or set. Each instance is identified by its key — which is stable and doesn’t shift when you add or remove other elements.

With a map:

variable "instances" {
  type = map(object({
    instance_type = string
    az            = string
  }))
  default = {
    "web-us-east-1a" = { instance_type = "t3.micro",  az = "us-east-1a" }
    "web-us-east-1b" = { instance_type = "t3.micro",  az = "us-east-1b" }
    "app-us-east-1a" = { instance_type = "t3.medium", az = "us-east-1a" }
  }
}

resource "aws_instance" "server" {
  for_each = var.instances

  ami               = "ami-0c55b159cbfafe1f0"
  instance_type     = each.value.instance_type
  availability_zone = each.value.az

  tags = { Name = each.key }
}

Resources created:
– aws_instance.server["web-us-east-1a"]
– aws_instance.server["web-us-east-1b"]
– aws_instance.server["app-us-east-1a"]

Remove "app-us-east-1a" from the map: only that specific resource is destroyed. The other two are completely untouched. This is the critical advantage over count — stable identity means surgical changes.

With a set of strings:

resource "aws_iam_user" "dev_team" {
  for_each = toset(["alice", "bob", "carol"])
  name     = each.key   # each.key == each.value for sets
}
each.key and each.value — The Iterator Variables
Contexteach.keyeach.value
MapThe map keyThe map value (can be complex)
SetThe set elementThe set element (same as key)
Referencing for_each Resources

The syntax differs from count because the underlying data structure is a map, not a list:

# Get all IPs as a list
output "instance_ips" {
  value = values(aws_instance.server)[*].private_ip
}

# Access a specific instance by key
output "web_east_1a_ip" {
  value = aws_instance.server["web-us-east-1a"].private_ip
}

With count, you use splat expressions: aws_instance.web[*].id. With for_each, you use values() first because the result is a map, not a list.

count vs for_each — The Decision Framework
Considerationcountfor_each
Identity modelNumeric indexString key
Adding/removing elementsShifts all indexes — cascading changesOnly the targeted element changes
Resource accessresource.name[0]resource.name["key"]
Input typeNumberMap or set
Best forIdentical, interchangeable resourcesResources with meaningful identity

The real-world rule: Default to for_each. Reach for count only when: (a) you need truly identical, interchangeable instances, or (b) you need a simple on/off toggle (count = var.create_this ? 1 : 0).

The on/off toggle pattern is count‘s most valuable use case:

resource "aws_cloudwatch_log_group" "app" {
  count = var.enable_logging ? 1 : 0
  name  = "/app/${var.environment}"
}

This creates the resource when enable_logging is true, and doesn’t create it when false.

Converting a List for for_each

for_each doesn’t accept lists directly — it needs a map or set. Convert with toset():

variable "regions" {
  type    = list(string)
  default = ["us-east-1", "eu-west-1", "ap-southeast-1"]
}

resource "aws_s3_bucket" "regional" {
  for_each = toset(var.regions)
  bucket   = "my-app-${each.key}"
}

toset() converts the list to a set — removing duplicates and disregarding order. If your list has duplicates, they’ll be silently deduplicated.

You can’t use both count and for_each on the same resource block. Pick one.

Design Scenario: Multi-AZ Subnets the Right Way
variable "subnets" {
  type = map(object({
    cidr   = string
    az     = string
    public = bool
  }))
  default = {
    public-1  = { cidr = "10.0.1.0/24", az = "us-east-1a", public = true }
    public-2  = { cidr = "10.0.2.0/24", az = "us-east-1b", public = true }
    private-1 = { cidr = "10.0.3.0/24", az = "us-east-1a", public = false }
    private-2 = { cidr = "10.0.4.0/24", az = "us-east-1b", public = false }
  }
}

resource "aws_subnet" "this" {
  for_each = var.subnets

  vpc_id                  = aws_vpc.main.id
  cidr_block              = each.value.cidr
  availability_zone       = each.value.az
  map_public_ip_on_launch = each.value.public

  tags = { Name = each.key }
}

To add a third AZ, add two entries to the map. No other resources are disturbed. To remove an AZ, remove its entries — only those specific subnets are destroyed. This is infrastructure management that scales.

Key Takeaways

The index-shifting problem with count is not a bug — it’s a consequence of positional identity. If resource identity is determined by position in a list, any insertion or removal in the middle cascades through every subsequent resource. for_each uses key-based identity, which is stable. This is why for_each is the default choice for most production scenarios.

The count = condition ? 1 : 0 pattern remains count‘s strongest use case — conditionally creating or not creating a resource. This is cleaner than any for_each alternative for on/off toggles.

When migrating from count to for_each on existing resources, you’ll need terraform state mv commands to remap resource[0] to resource["key"]. The migration is mechanical but important — without it, Terraform sees a destroy-and-recreate for every resource.