8.1 Expressions, count, for_each, and dynamic Blocks

Key Takeaways

  • Conditional expressions use condition ? true_val : false_val; both result branches must share a type so Terraform can type the whole expression before it knows the condition
  • A splat such as aws_instance.web[*].id is shorthand for a for expression that pulls one attribute from every element of a list; it does not walk a for_each map
  • Use count for nearly identical instances tracked by a 0-based index; use for_each when instances have distinct map or set keys that must stay stable
  • count.index, each.key, and each.value are block-local; you cannot set both count and for_each on the same resource or module
  • count and for_each must be known at plan time — they cannot wait on a remote ID that is only known after apply — and dynamic blocks generate nested repeating blocks such as ingress, not extra resource instances
Last updated: August 2026

8.1 Expressions, count, for_each, and dynamic Blocks

Quick Answer: A conditional is condition ? true_val : false_val. A splat list[*].id is a short for. count multiplies nearly identical instances and tracks them as [0], [1], [2]. for_each multiplies instances that have distinct map or set keys and tracks them as ["app"]. You cannot set both on the same block. Both must be known at plan time. A dynamic block expands nested repeating blocks such as ingress; it does not create extra resources.

Objective 4e on Terraform Associate (004) is: write dynamic configuration using expressions and functions. This section is the expression and meta-argument half. Section 8.2 is the function catalog. HashiCorp's 004 content list points at Terraform 1.12 Expressions, count, for_each, and dynamic blocks. The unversioned language pages (Expressions, count, for_each) teach the same rules.

Conditional expressions

Official syntax is a ternary:

resource "aws_instance" "web" {
  ami           = data.aws_ami.al2023.id
  instance_type = var.environment == "prod" ? "m5.large" : "t3.micro"
}

If the condition is true, the result is the first value; otherwise it is the second. Both result branches may be any type, but they must be the same type (or convertible to one) so Terraform can type the whole expression without knowing the condition. var.flag ? 12 : "hello" is legal only because Terraform can convert the number to a string. Prefer tostring(12) when you mean a string so the exam answer is explicit.

Teams also use a conditional to turn a resource off:

resource "aws_eip" "nat" {
  count  = var.enable_nat ? 1 : 0
  domain = "vpc"
}

count = 0 creates zero instances. That is the documented pattern for an optional resource. for_each = var.enable_nat ? toset(["nat"]) : toset([]) is the same idea with keys.

Splat expressions

A splat is a shorter for that walks a list (or a single object, which Terraform wraps as a one-element list) and pulls the same attribute from every element:

output "web_ids" {
  value = aws_instance.web[*].id          # count resource → list of ids
}

# equivalent for expression
# value = [for inst in aws_instance.web : inst.id]

var.list[*].interfaces[0].name keeps walking to the right of [*]. Official docs treat [*] as the current form; the older .*. splat still works but is legacy.

A splat does not walk a for_each map. aws_instance.web is a map of objects when for_each is set, so aws_instance.web[*].id is the wrong shape. Use values(aws_instance.web)[*].id or { for k, inst in aws_instance.web : k => inst.id }.

for expressions

A for expression transforms one collection into another. Square brackets produce a tuple; braces produce an object:

locals {
  upper_names = [for s in var.names : upper(s)]
  name_map    = { for s in var.names : s => upper(s) }
  only_web    = [for s in var.names : s if startswith(s, "web-")]
}

The optional if clause drops elements (zero-or-one output per input). Two temporary symbols give you the key or index: { for az, cidr in var.subnet_cidrs : az => cidr }. Input may be a list, set, tuple, map, or object. This is not for_each. A for expression is a value. for_each is a meta-argument that creates instances.

ConstructWhat it producesWhere you write it
condition ? a : bOne of two valuesAny expression
list[*].attrA list of attributesAny expression over a list
[for x in coll : expr]A tupleAny expression
{ for k, v in coll : k => v }An objectAny expression
countN resource or module instancesMeta-argument on the block
for_eachOne instance per map/set elementMeta-argument on the block
dynamic "ingress"N nested blocks inside one resourceInside resource, data, provider, or provisioner

count versus for_each

Official wording: use count when you want nearly identical instances; use for_each when some arguments have distinct values that cannot be derived from an integer. You cannot put both count and for_each on the same resource or module block.

resource "aws_instance" "identical" {
  count         = 4
  ami           = data.aws_ami.al2023.id
  instance_type = "t3.micro"
  tags = {
    Name = "server-${count.index}"   # 0, 1, 2, 3
  }
}

resource "aws_iam_user" "accounts" {
  for_each = toset(["alice", "bob", "cara"])
  name     = each.key                 # each.value is the same on a set
}

resource "azurerm_resource_group" "rg" {
  for_each = tomap({
    app = "eastus"
    job = "westus2"
  })
  name     = each.key
  location = each.value
}

Inside a count block Terraform injects a count object with count.index, a 0-based integer. Inside a for_each block it injects each.key (map key or set member) and each.value (map value; equal to each.key on a set). Those names are block-local. Another resource does not magically see count.index; it addresses a specific instance as aws_instance.identical[0] or aws_iam_user.accounts["alice"].

for_each accepts a map or a set of strings. It does not implicitly convert a list. Write toset(var.names) or give the variable type set(string) / map(...). Sensitive values cannot be for_each keys because Terraform prints those keys in the UI. Impure functions such as timestamp(), uuid(), and bcrypt() cannot feed for_each either — the keys would change every plan.

Why count indexes are unstable

This is the 004 trap that decides count versus for_each.

Suppose var.names = ["web", "api", "job"] and you write count = length(var.names) with Name = var.names[count.index]. State holds [0]=web, [1]=api, [2]=job. Remove "api" from the list. The list is now ["web", "job"]. Terraform still thinks [1] is the old api instance, but configuration now says [1] should be job. Plan: change [1] from api to job (an in-place update if only tags moved, a replace if a ForceNew argument such as subnet_id = var.subnet_ids[count.index] also shifted), and destroy [2]. The live job object that lived at [2] is the one that disappears. You wanted to delete only api.

for_each = toset(var.names) keys instances as ["web"], ["api"], ["job"]. Removing "api" destroys only ["api"]. The other two keys are untouched. That is why HashiCorp tells you to pick for_each when each instance has a stable identity.

You can still use count for a fixed pool (count = 3 identical workers) or the count = var.enabled ? 1 : 0 toggle. Do not key a changing inventory off a list index.

Known at plan time

Unlike ordinary arguments, count and for_each must be known before Terraform performs any remote resource operations. Official count docs: the value cannot refer to attributes that are only known after apply, such as a unique ID the remote API assigns at create. Official for_each docs say the same, and add that keys cannot come from impure functions. If you write for_each = toset(aws_instance.web[*].id) on a resource that is being created in the same run, plan fails with a “dependencies that cannot be determined before apply” error.

Feed count / for_each from input variables, locals, data sources whose arguments are already known, or other resources that already exist. Do not feed them from a brand-new computed id.

dynamic blocks — nested repeating blocks

A resource argument uses name = expression. A nested block such as ingress { ... } or Beanstalk setting { ... } is a literal block, not an expression. dynamic is the construct that expands those nested blocks from a collection. It does not create extra aws_security_group resources. count / for_each on the resource do that.

variable "ingress_rules" {
  type = list(object({
    port     = number
    protocol = string
    cidr     = string
  }))
}

resource "aws_security_group" "web" {
  name = "web"

  dynamic "ingress" {
    for_each = var.ingress_rules
    content {
      from_port   = ingress.value.port
      to_port     = ingress.value.port
      protocol    = ingress.value.protocol
      cidr_blocks = [ingress.value.cidr]
    }
  }
}

The label ("ingress") is the nested-block type to generate. for_each is the collection. The iterator defaults to that label (ingress.key, ingress.value); set iterator = rule if the name would clash. The nested content block is the body of each generated block. An empty for_each produces zero nested blocks — that is how you make an optional ingress.

You cannot generate meta-argument blocks such as lifecycle or provisioner with dynamic. Terraform must process those before it is safe to evaluate expressions. Official guidance: write nested blocks literally unless you are hiding repetition behind a module interface.

You can nest dynamic blocks when the provider schema is multi-level (origin_group containing origin). Each level has its own iterator. origin_group.value is the outer element; origin.value is the inner one.

004 traps for expressions and meta-arguments

  • count and for_each are mutually exclusive on one block.
  • count.index is an integer. each.key is a string key. aws_instance.web[0] is not the for_each instance named "0".
  • Removing a middle list item under count shifts later indexes and replaces innocent instances.
  • for_each does not take a raw list. Convert with toset or declare set(string) / map(...).
  • A splat does not walk a for_each map. Use values(...) or a for.
  • dynamic expands nested blocks. It is not a third way to create resources.
  • count / for_each cannot wait on a computed remote ID.
Loading diagram...
count indexes shift; for_each keys stay put
Test Your Knowledge

On Terraform 1.12, which statement about count and for_each on the same resource or module block is correct?

A
B
C
D
Test Your Knowledge

A module used count = length(var.names) with var.names = ["web", "api", "job"]. An operator removes "api" so the list is ["web", "job"]. Why do later instances often get replaced?

A
B
C
D
Test Your Knowledge

You need a variable number of ingress rules on a single aws_security_group. Which Terraform 1.12 construct generates those nested blocks?

A
B
C
D