Terraform – count, for_each and Dynamic Blocks

August 3, 20265 min readUpdated 8/24/2026

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.