Three subnets should not be three copy-pasted blocks. Terraform has two ways to say "one of these per item", they behave very differently when the list changes, and picking the wrong one is how people destroy resources they meant to keep.
count
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.this.id
cidr_block = ["10.20.0.0/24", "10.20.1.0/24"][count.index]
availability_zone = ["us-west-2a", "us-west-2b"][count.index]
}count.index is 0-based. The resources are addressed by position:
aws_subnet.public[0]
aws_subnet.public[1]
aws_subnet.public[*].id # splat — the list of all their ids⚠️ Why count will destroy your resources
This is the single most important thing in this lesson.
count keys state by list position. Remove an element from the middle, and every element after it shifts down — so Terraform sees the resource at each index change identity.
locals {
# before
buckets = ["logs", "uploads", "backups"]
# after — "uploads" removed
# buckets = ["logs", "backups"]
}You deleted one bucket. Terraform plans this:
[0] "logs" -> "logs" no change
[1] "uploads" -> "backups" ~ UPDATE IN PLACE, renaming the bucket
[2] "backups" -> gone - DESTROY
Plan: 0 to add, 1 to change, 1 to destroy.The bucket named backups — which you did not touch — is destroyed, and the one named
uploads is renamed to backups. On an S3 bucket that means the contents of
the original backups are gone. On an RDS instance it is worse.
Use count only when the items are genuinely positional and interchangeable,
or when the count is a number you control. Two subnets indexed by AZ are fine — the list is
fixed and derived. A list of named things is not.
for_each
resource "aws_s3_bucket" "this" {
for_each = toset(["logs", "uploads", "backups"])
bucket = "pizza-tf-${each.key}"
}for_each keys state by the key, not the position. Remove "uploads"
and Terraform plans exactly one thing:
aws_s3_bucket.this["uploads"] will be destroyed
Plan: 0 to add, 0 to change, 1 to destroy.The others are not touched, because their keys did not change. That is the whole difference, and
it is why for_each is the correct default.
It takes a set of strings or a map. A list must be converted with
toset() — which also means duplicates are silently collapsed, since a set cannot hold
them.
With a map you get both halves:
variable "services" {
type = map(object({
port = number
memory = string
}))
default = {
api = { port = 8085, memory = "1024" }
worker = { port = 8086, memory = "512" }
}
}
resource "aws_cloudwatch_log_group" "service" {
for_each = var.services
name = "/ecs/${each.key}"
retention_in_days = 7
tags = {
Port = tostring(each.value.port)
}
}each.key "api"
each.value { port = 8085, memory = "1024" }
aws_cloudwatch_log_group.service["api"]
aws_cloudwatch_log_group.service["worker"]⚠️ The for_each restriction that will catch you
The keys of a for_each must be known at plan time. The values can be
computed; the keys cannot.
Error: Invalid for_each argument
The "for_each" map includes keys derived from resource attributes that cannot
be determined until apply, and so Terraform cannot determine the full set of
keys that will identify the instances of this resource.This happens when you feed for_each something derived from a resource that does not
exist yet — bucket names built from a generated id, subnets keyed by an ARN. Terraform needs the
keys to build the graph, and it builds the graph before it creates anything.
The fix is to key on something you already know — a variable, a literal, a name you chose — and put the computed thing in the value.
Switching between them
Changing count to for_each changes every address, so Terraform plans to
destroy everything and create it again. Use moved blocks:
moved {
from = aws_s3_bucket.this[0]
to = aws_s3_bucket.this["logs"]
}
moved {
from = aws_s3_bucket.this[1]
to = aws_s3_bucket.this["uploads"]
}count as a conditional
There is no if for whether a resource exists. This idiom is how it is done:
variable "enable_monitoring" {
type = bool
default = false
}
resource "aws_cloudwatch_log_group" "optional" {
count = var.enable_monitoring ? 1 : 0
name = "/ecs/pizza-tf-monitoring"
retention_in_days = 7
}⚠️ It is now a list, even with one element, so every reference needs
[0] — and that reference errors when the count is 0. The safe form:
output "log_group_name" {
# one(...) returns the single element, or null if the list is empty
value = one(aws_cloudwatch_log_group.optional[*].name)
}The conditional expression
locals {
env = "prod"
instance_type = local.env == "prod" ? "db.r6g.large" : "db.t4g.micro"
backup_days = local.env == "prod" ? 30 : 0
}Both branches must be the same type. And ⚠️ both branches are evaluated, so
condition ? a[0] : "default" still errors when a is empty.
for expressions
Transforming a collection, in the expression rather than in the resource:
locals {
services = {
api = { port = 8085 }
worker = { port = 8086 }
}
# list from a map
names = [for name, cfg in local.services : name]
# map from a map — note the => and the surrounding braces
ports = { for name, cfg in local.services : name => cfg.port }
# with a filter
web = { for name, cfg in local.services : name => cfg if cfg.port == 8085 }
# building the for_each input for a nested resource
rules = [for port in [80, 443] : {
from_port = port
to_port = port
}]
}dynamic blocks
For repeating a nested block rather than a resource. Reach for it only when the number of blocks is genuinely variable:
variable "ingress_ports" {
type = list(number)
default = [80, 443]
}
resource "aws_security_group" "web" {
name = "pizza-tf-web"
vpc_id = "vpc-example"
dynamic "ingress" {
for_each = var.ingress_ports
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
}The iterator is named after the block — ingress.value — which reads oddly until you
have seen it a few times.
⚠️ dynamic is the least readable construct in HCL, and two literal blocks are almost
always clearer than a dynamic over a two-element list. Use it when the count is driven
by an input, not to avoid repeating yourself twice.
For security groups specifically, prefer the separate rule resources —
aws_vpc_security_group_ingress_rule — with for_each. Each rule is then its
own addressable resource, so a plan shows exactly which rule changed rather than a diff of a nested
block.
Which to use
for_each DEFAULT. named things, anything that might be removed from the middle.
count a number you control, or the ? 1 : 0 conditional idiom.
dynamic a nested block whose repetition is driven by an input.
for transforming a collection inside an expression.Next
Lesson 10 packages a directory of Terraform so it can be called more than once — and is honest about when a module is the wrong answer.