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.
| Use | When |
|---|---|
count | Genuinely identical instances where position is meaningless, or 0/1 conditionals |
for_each | Anything 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.