Count, for_each, Dynamic Blocks, and Stable Addresses

Terraform repetition is not just a way to write fewer lines. count, for_each, and dynamic decide how many configuration instances Terraform expands before it builds the dependency graph, and the resulting resource addresses become the keys that bind configuration to objects in state. The outcome of this lesson is practical: choose the repetition construct that keeps addresses stable when a collection changes, use dynamic blocks only for repeatable nested arguments, and predict when a plan will update, create, destroy, or replace objects.

Purpose and Outcome

In the Terraform language section of this course, you have already seen that state is Terraform’s memory of remote objects. This chapter focuses on the part of the language that multiplies blocks. The main design question is not whether a loop is shorter. It is whether the identity Terraform assigns to each instance will survive ordinary changes such as adding a server, renaming an environment, or removing one firewall rule.

After this lesson, you should be able to read addresses such as terraform_data.server[0] and terraform_data.server["api"], explain why they behave differently, refactor from count to for_each with a moved block, and diagnose plans that propose unexpected destroys because instance keys changed.

How Terraform Expands Instances

Terraform first evaluates expressions that must be known during planning, including meta-arguments such as count and for_each. A normal resource block has one instance. A block with count = 3 expands to three instances addressed by zero-based indexes. A block with for_each expands to one instance per map key or set member. Terraform then uses those addresses while building the graph, comparing configuration with state, and asking providers to plan changes.

The important internal detail is that the address is part of identity. With count, identity is positional: instance [1] means the second item after Terraform evaluates the list. With for_each, identity is keyed: instance ["billing"] means the item whose key is billing. If the second element of a list becomes a different logical server, Terraform still sees address [1]. If a map gains a new key, the existing keys keep their addresses.

dynamic works at a different level. It does not create additional resources and it does not create resource addresses. It generates repeated nested blocks inside one enclosing block, such as several ingress blocks inside an AWS security group. Use it when the provider schema expects repeatable nested blocks and the number of those blocks is data driven. Do not use it to choose between top-level resources; that is what count, for_each, modules, and ordinary expressions are for.

Syntax Anatomy

count accepts a whole number known during planning. Inside the repeated block, count.index exposes the current numeric index. for_each accepts a map or a set of strings known during planning. Inside that block, each.key is the instance key and each.value is the mapped value. A dynamic block has a label matching the nested block to generate, a for_each collection, an optional iterator name, and a content block that supplies the nested arguments.

The collection for for_each should have keys that are chosen deliberately, not derived from values likely to change. Good keys are short stable names such as api, worker, or account IDs already treated as durable identifiers. Poor keys include display names, timestamps, descriptions, or list indexes converted to strings. A key change is an address change, so Terraform treats it as one instance removed and another added unless you explicitly declare the move.

Example 1: Count for Identical Instances

count is reasonable when instances are interchangeable and their numeric position is not meaningful outside Terraform. This example creates three local Terraform-managed data objects with predictable names. The resource type is terraform_data, which is built into Terraform and is useful for language demonstrations because no cloud credentials are required.

variable "server_count" {
  type    = number
  default = 3
}

resource "terraform_data" "server" {
  count = var.server_count

  input = {
    name = "web-${count.index + 1}"
    role = "web"
  }
}

output "server_names" {
  value = terraform_data.server[*].output.name
}

With the default value, Terraform expands addresses terraform_data.server[0], terraform_data.server[1], and terraform_data.server[2]. The output is deterministically ["web-1", "web-2", "web-3"] after apply. If you change server_count from 3 to 4, Terraform adds [3]. If you change it to 2, Terraform destroys [2]. That is clear for a pool where the last instance can appear or disappear.

The trade-off appears when the collection is not just a count. If you use count.index to read from a list of named servers and then remove the first list element, every later index now refers to different input. Terraform may update or replace several instances even though you intended to remove only one logical server. That behavior is not a bug; it follows from positional identity.

Example 2: for_each for Stable Named Instances

When each instance has a durable name, use for_each. The keys become part of the addresses, so adding or removing one key leaves the others alone. The values can carry mutable attributes without changing identity.

variable "services" {
  type = map(object({
    port = number
    tier = string
  }))
  default = {
    api = {
      port = 8080
      tier = "public"
    }
    worker = {
      port = 9090
      tier = "private"
    }
  }
}

resource "terraform_data" "service" {
  for_each = var.services

  input = {
    name = each.key
    port = each.value.port
    tier = each.value.tier
  }
}

output "service_ports" {
  value = { for name, svc in terraform_data.service : name => svc.output.port }
}

This expands to terraform_data.service["api"] and terraform_data.service["worker"]. The output is { api = 8080, worker = 9090 } in Terraform’s displayed form. If you add a jobs key, the plan creates only terraform_data.service["jobs"]. If you change api.port to 8081, the address remains ["api"]; Terraform plans an in-place update for this built-in resource. With a real provider, whether an attribute update is in-place or replacement depends on that provider’s schema, but the address identity still remains stable.

A set of strings can also drive for_each. In that case, each.key and each.value are the same string. Sets are useful for names alone, but maps are usually clearer because they separate stable identity from mutable configuration.

Example 3: Dynamic Nested Blocks

Provider schemas often contain nested blocks that may repeat. Security group rules, listener actions, route entries, and lifecycle policy rules are common examples. A dynamic block lets you generate those nested blocks from a collection while keeping a single enclosing resource address.

variable "ingress_rules" {
  type = map(object({
    from_port   = number
    to_port     = number
    protocol    = string
    cidr_blocks = list(string)
  }))
}

resource "aws_security_group" "app" {
  name        = "app"
  description = "Application ingress"
  vpc_id      = var.vpc_id

  dynamic "ingress" {
    for_each = var.ingress_rules
    iterator = rule

    content {
      description = rule.key
      from_port   = rule.value.from_port
      to_port     = rule.value.to_port
      protocol    = rule.value.protocol
      cidr_blocks = rule.value.cidr_blocks
    }
  }
}

If ingress_rules has keys http and https, Terraform generates two nested ingress blocks inside aws_security_group.app. It does not create addresses such as aws_security_group.app.ingress["http"]. State still tracks one security group resource, while the provider receives a planned object containing the nested rule blocks. That distinction matters for imports and troubleshooting: you cannot target a generated nested block with -target because it is not a Terraform resource instance.

Example 4: Refactoring Without Losing State

Suppose a module began with count and later needs named service instances. Changing addresses directly from terraform_data.server[0] to terraform_data.service["api"] would normally look like destroy old, create new. A moved block records the address migration so Terraform can reinterpret existing state.

moved {
  from = terraform_data.server[0]
  to   = terraform_data.service["api"]
}

moved {
  from = terraform_data.server[1]
  to   = terraform_data.service["worker"]
}

After adding the destination resource block and the matching moved blocks, run a plan and inspect the messages. Terraform should report that objects have moved rather than proposing destruction. Keep the move declarations long enough for every workspace that uses the module to apply the migration. Removing them too early can surprise a workspace that has not yet crossed the refactor boundary.

Design Choices and Trade-offs

Choose count for optional singletons and fungible pools. A common optional pattern is count = var.enabled ? 1 : 0, but references must then account for the list-like result, often with one(resource.name[*].id) or a conditional expression. Choose for_each when instances have names, owners, environments, accounts, regions, or any identity that humans discuss independently. Choose dynamic when a single resource needs repeated nested configuration and the provider schema does not offer a separate resource type that would give better lifecycle control.

The main trade-off with for_each is key stewardship. Keys become part of the module contract. Renaming frontend to web may be semantically harmless to a person, but it is a destroy-and-create signal to Terraform unless represented as a move. The main trade-off with dynamic is readability. It can hide provider arguments behind iterator expressions. If only two literal nested blocks are needed and they rarely change, static blocks are easier to review.

Failure Modes and Troubleshooting

Unexpected destroy after list edit. The symptom is a plan where several count instances update or replace after you removed one item from the middle of an input list. The cause is index shifting. Diagnose by comparing the old and new values assigned to each count.index and by checking addresses in state with terraform state list. Correct it by using for_each with stable keys, then add moved blocks for the one-time migration.

Invalid for_each argument. The symptom is a planning error saying Terraform cannot determine the full set of keys until apply. The cause is using values derived from resources that do not exist yet, such as generated IDs, as for_each keys. Diagnose by finding references inside the for_each expression and asking whether each value is known during planning. Correct it by choosing caller-supplied keys and putting unknown remote IDs in each.value, not in the key set.

Dynamic block label rejected. The symptom is an error such as unsupported block type for the generated block. The cause is usually a mismatch between the dynamic label and the provider schema. Diagnose with the provider documentation or terraform providers schema -json when you need exact schema names. Correct it by using the nested block name the resource actually supports, or by modeling the item as a separate resource when the provider exposes one.

Renamed map key causes replacement. The symptom is one for_each instance destroyed and another created with identical attributes. The cause is that the address key changed. Diagnose by comparing old and new keys, not only values. Correct it with a moved block when the same real object is being renamed, or accept the replacement when the new key is intentionally a new object.

Reliability, Security, and Performance Implications

Stable addresses are reliability controls. They reduce accidental churn, make plans smaller, and make review easier because one logical change maps to one logical address. They also help during incident response: a state address that includes ["payments"] is easier to discuss than one that depends on whichever index happened to hold that service.

Security review benefits from keyed collections because ownership and policy can be encoded by name. For example, a map keyed by account alias can require each value to include an approved role ARN and tags. Performance is usually affected indirectly: large generated collections create larger plans and more provider operations. Prefer maps filtered before resource expansion, avoid generating thousands of resources in one module when ownership boundaries are different, and remember that dynamic blocks can still produce large provider requests even though they do not create separate Terraform addresses.

Hands-on Lab: Observe Address Stability

Prerequisites: Terraform CLI installed, an empty working directory, and no cloud credentials. Create a file named main.tf with the first lab configuration, then run terraform init, terraform plan, and terraform apply. The apply should create three terraform_data.server instances and output ["web-1", "web-2", "web-3"].

variable "names" {
  type    = list(string)
  default = ["api", "worker", "jobs"]
}

resource "terraform_data" "server" {
  count = length(var.names)

  input = {
    name = var.names[count.index]
  }
}

output "names_by_count" {
  value = terraform_data.server[*].output.name
}

Next, remove "api" from the list and run terraform plan. Verification: the plan shows the remaining numeric addresses now contain different names, because worker moved from index 1 to index 0. Do not apply this change. Replace the file with the keyed version below and add move declarations if you are migrating already-created objects.

variable "services" {
  type = map(string)
  default = {
    api    = "public"
    worker = "private"
    jobs   = "private"
  }
}

resource "terraform_data" "service" {
  for_each = var.services

  input = {
    name = each.key
    tier = each.value
  }
}

output "tiers_by_service" {
  value = { for key, svc in terraform_data.service : key => svc.output.tier }
}

Run terraform plan again in a clean directory, or use terraform destroy first if you applied the count version and are not practicing a moved-block migration. Verification: deleting the api key affects only terraform_data.service["api"]. Cleanup: run terraform destroy when finished and remove the lab directory. If you practiced a move, keep the moved blocks until every environment has applied them.

Assessment Exercises

  1. A module creates subnets from a list with count. A new subnet must be inserted at the beginning. Predict the plan risk and redesign the input shape to avoid address churn.
  2. You have a map keyed by service display name, and the business wants to rename Checkout to Payments. Decide whether to use a moved block, a new key, or a different key strategy, and justify the choice.
  3. A dynamic "rule" block fails with an unsupported block type error. Describe the diagnostic steps that distinguish a spelling mistake from a provider schema limitation.
  4. An expression uses generated database IDs as for_each keys and fails during planning. Rewrite the design so keys are known before apply while IDs can still be used inside resource arguments.
  5. Compare an optional resource implemented with count = var.enabled ? 1 : 0 to one implemented with for_each = var.enabled ? { main = var.config } : {}. Explain how downstream references differ.

Summary

count, for_each, and dynamic are Terraform expansion tools with different identity consequences. count creates index-addressed instances, best for optional resources and interchangeable pools. for_each creates key-addressed instances, best for named infrastructure that must survive collection edits. dynamic generates nested blocks inside one resource and does not create separate addresses. The reliable habit is to design keys first, review the resulting addresses in the plan, and use moved blocks whenever a refactor changes the address of an object that should continue to exist.