count, for_each & Dynamic Blocks

How to create many resources from one block, why choosing count over for_each is the most common self-inflicted outage in Terraform, migrating between them safely, and when generating nested blocks stops being worth it.

intermediate 20 min lesson hands-on task included

You rarely want one subnet. You want one per availability zone, and adding a zone should be a one-line change. Terraform gives you two ways to do that, and the choice has consequences far beyond style.


Topic 1: count

count creates N copies, indexed by position.

resource "aws_subnet" "public" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone = var.availability_zones[count.index]

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

count.index is available inside the block. Reference all instances with the splat operator:

subnet_ids = aws_subnet.public[*].id

Set count = 0 and nothing is created — that is how conditional creation works. Note the resource is now a list even with one element, so you reference aws_lb.public[0].dns_name.


Topic 2: for_each

for_each creates instances keyed by a map key or set member.

resource "aws_instance" "app" {
  for_each = {
    web = "t3.micro"
    api = "t3.small"
    job = "t3.medium"
  }

  ami           = data.aws_ami.base.id
  instance_type = each.value
  tags          = { Name = each.key }
}

each.key and each.value are available inside the block. for_each requires a map or a set of strings — a list is rejected:

for_each = toset(var.subnet_cidrs)
for_each = { for cidr in var.subnet_cidrs : cidr => cidr }

Topic 3: Why the Choice Causes Outages

This is the part to internalise. It is not a style preference.

Terraform tracks each instance by a resource address. With count, the address is the index:

aws_subnet.public[0]
aws_subnet.public[1]
aws_subnet.public[2]

Remove the middle element and the addresses shift. What was [2] is now [1]. Terraform does not see “one item removed” — it sees that [1] and [2] now describe different things:

aws_subnet.public[1]  -/+ destroy and then create replacement
aws_subnet.public[2]  -   destroy

Two resources churn to remove one. On subnets that is disruptive; on stateful resources it is an incident.

With for_each, the address is the key:

aws_instance.app["web"]
aws_instance.app["api"]
aws_instance.app["job"]

Remove api and the plan is exactly one destroy. The others keep their addresses because their keys never changed. Reordering the map changes nothing, because maps are unordered.

UseWhen
countGenuinely identical instances where position is meaningless, or 0/1 conditionals
for_eachAnything where an individual instance has an identity worth keeping

In production that means for_each most of the time. The rule of thumb: if you can imagine removing one item from the middle, use for_each.


Topic 4: Migrating count to for_each

The addresses change, so a naive switch destroys and recreates everything. Move each instance in state first:

terraform state mv 'aws_subnet.public[0]' 'aws_subnet.public["eu-west-1a"]'
terraform state mv 'aws_subnet.public[1]' 'aws_subnet.public["eu-west-1b"]'
terraform plan     # must report: No changes

That plan reporting no changes is the confirmation the migration was lossless. Do this before anyone depends on the resources, not after.


Topic 5: Dynamic Blocks

count and for_each repeat whole resources. Dynamic blocks repeat nested blocks inside a resource.

variable "ingress_rules" {
  type = list(object({
    from        = number
    to          = number
    protocol    = string
    cidr_blocks = list(string)
    description = string
  }))
}

resource "aws_security_group" "app" {
  name = "app-sg"

  dynamic "ingress" {
    for_each = var.ingress_rules

    content {
      from_port   = ingress.value.from
      to_port     = ingress.value.to
      protocol    = ingress.value.protocol
      cidr_blocks = ingress.value.cidr_blocks
      description = ingress.value.description
    }
  }
}

The iterator is named after the block (ingress.value), and adding a rule becomes a variable edit rather than a code edit.

The counter-argument:

Overusing dynamic blocks makes configuration significantly harder to read. A security group with three static ingress blocks is obvious at a glance; the same group expressed dynamically requires the reader to hold a variable’s structure in their head.

Reach for one when the number of blocks is genuinely variable. Do not reach for one to avoid typing three static blocks you will never change.


Topic 6: For Expressions

The same iteration idea applied to values rather than resources:

[for item in var.names : upper(item)]                    # list -> list
{for k, v in var.tags : k => upper(v)}                   # map -> map
{for cidr in var.subnets : cidr => cidr}                 # list -> map, for for_each
[for s in var.subnets : s if s.public]                   # with a filter

The list-to-map form is the one you will write most, because it is how you feed a list into for_each while giving each item a stable key.


Try it yourself: Build three resources with count from a list, delete the first element, and read the plan. Every resource churns. That single plan output is more persuasive than any explanation.

Common mistake: Using count with a list humans will edit over time — availability zones, team names, environments. It works perfectly until the first removal from the middle, which is usually months later, in production, when nobody remembers the index is the identity.