Terraform Basics #6 Dependencies and Repetition: depends_on, count vs for_each, and dynamic Blocks

5 min read

Two questions that never come up with two or three resources appear as soon as your infrastructure grows: in what order are resources that depend on each other created, and do you really have to copy-paste ten nearly identical resources ten times? This post covers how Terraform decides ordering, and the three constructs that eliminate repetition: count, for_each, and dynamic blocks. count in particular has a well-known trap that destroys resources in real-world use, and understanding that behavior by connecting it to the state we learned in #5 is the heart of this post.

A reference is a dependency #

In #2, the versioning resource referenced aws_s3_bucket.hello.id. That reference is syntax for fetching a value, and at the same time it is an ordering declaration. The bucket has to exist before its id can be known, so Terraform creates the bucket first and the versioning afterwards. Collect every reference in the code and you get a directed graph; Terraform executes along that graph, and resources with no dependencies between them are created in parallel. There is no separate ordering syntax — the good habit of wiring values together through references makes the ordering problem solve itself.

depends_on: the escape hatch for reference-free dependencies #

Occasionally there is no reference, but ordering still matters. The classic example is IAM permissions. Say an EC2 instance accesses S3 from its boot script, but there is no reference between the instance and the policy attachment resource that grants that permission. Terraform creates them in parallel, and the instance may come up first, try to reach S3 without the permission, and fail. This is when you force the ordering explicitly.

main.tf
resource "aws_instance" "web" {
  ami           = data.aws_ami.al2023.id
  instance_type = "t3.micro"

  depends_on = [aws_iam_role_policy_attachment.web_s3]
}

The thing to keep in mind is that depends_on is a last resort. If you write a dependency as depends_on when it could be expressed as a reference, the graph becomes invisible in the code, and unnecessary depends_on entries reduce parallelism and only make apply slower. Use a reference whenever one is possible, and depends_on only when it truly is not.

count: duplicating by number #

When you need multiple copies of the same resource, the first tool is count.

main.tf
resource "aws_instance" "web" {
  count = 3

  ami           = data.aws_ami.al2023.id
  instance_type = "t3.micro"

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

The resource addresses expand to aws_instance.web[0], [1], [2], and inside the block you can use count.index to get each copy’s own number. Conditional creation, combined with the conditional expression from #4, is also count’s job.

main.tf
count = var.env == "prod" ? 1 : 0

Expressing “this resource exists only in production” in this one line is a widely used idiom.

The count trap: shifted indexes mean recreation #

The identity of a resource created with count is recorded in state by number. This is where the trap comes from. Say you created three subnets from a list.

main.tf
variable "azs" {
  default = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"]
}

resource "aws_subnet" "public" {
  count             = length(var.azs)
  availability_zone = var.azs[count.index]
  # ...
}

What happens if you remove 2b from the middle of the list? 2c shifts from index 2 down to index 1. From Terraform’s point of view, [1] used to be 2b but must now become 2c — a replacement — and [2] no longer exists, so it must be deleted. The intent was “delete only 2b,” but the plan proposes to destroy and recreate 2c as well. If anything was living on that subnet, that is an incident. The root cause is that the resource’s identity is tied to its position, not its content.

for_each: duplicating by key #

The answer to this trap is for_each. Instead of a number, a map key or a set value becomes the identity.

main.tf
variable "azs" {
  default = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"]
}

resource "aws_subnet" "public" {
  for_each          = toset(var.azs)
  availability_zone = each.value
  # ...
}

Addresses are recorded by key, like aws_subnet.public["ap-northeast-2a"], so removing 2b marks exactly ["ap-northeast-2b"] for deletion and leaves the rest untouched. Pass a map and you get both each.key and each.value, which is convenient for pairing names with settings. The one thing to remember is that a list cannot be passed as-is — it has to be wrapped in toset().

To sum up the decision rule: use count for N truly identical copies or for conditional creation (0 or 1); use for_each for a collection where each item has its own name or settings. In real-world code, for_each is the right answer far more often.

dynamic blocks: repetition for nested blocks #

count and for_each duplicate the resource itself. But sometimes the repetition you need is in a nested block inside a resource. Security group ingress rules are the classic case.

main.tf
variable "service_ports" {
  default = [80, 443]
}

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

  dynamic "ingress" {
    for_each = var.service_ports
    content {
      from_port   = ingress.value
      to_port     = ingress.value
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    }
  }
}

Write for_each and content inside dynamic "block-name", and content unrolls once per iteration into an ingress block. Note that the iteration variable is not each but the block name (here, ingress.value). It is convenient, but overuse makes code hard to read — if the rules are fixed at two or three, simply writing the block twice is often the clearer choice.

Recap #

What we covered in this post:

  • A reference is a dependency. Terraform orders execution by the reference graph and creates independent resources in parallel
  • depends_on is a last resort for reference-free ordering dependencies; overusing it obscures the graph and slows apply down
  • count duplicates by number. The condition ? 1 : 0 conditional-creation idiom is useful, but removing an item from the middle of a list shifts the numbers and triggers unintended recreation
  • for_each duplicates by key, so it is safe against middle removals. Named collections take for_each; identical copies and conditional creation take count
  • Repetition in nested blocks is solved with dynamic blocks — remember that the iteration variable shares the block’s name

In the next post (#7 Resource Lifecycle Control), we cover how to control the replacement we met in #2: create_before_destroy for zero-gap replacement, prevent_destroy for protecting production resources, and ignore_changes for tolerating drift.

X