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
8.1 Expressions, count, for_each, and dynamic Blocks
Quick Answer: A conditional is
condition ? true_val : false_val. A splatlist[*].idis a shortfor.countmultiplies nearly identical instances and tracks them as[0],[1],[2].for_eachmultiplies 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. Adynamicblock expands nested repeating blocks such asingress; 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.
| Construct | What it produces | Where you write it |
|---|---|---|
condition ? a : b | One of two values | Any expression |
list[*].attr | A list of attributes | Any expression over a list |
[for x in coll : expr] | A tuple | Any expression |
{ for k, v in coll : k => v } | An object | Any expression |
count | N resource or module instances | Meta-argument on the block |
for_each | One instance per map/set element | Meta-argument on the block |
dynamic "ingress" | N nested blocks inside one resource | Inside 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
countandfor_eachare mutually exclusive on one block.count.indexis an integer.each.keyis a string key.aws_instance.web[0]is not thefor_eachinstance named"0".- Removing a middle list item under
countshifts later indexes and replaces innocent instances. for_eachdoes not take a raw list. Convert withtosetor declareset(string)/map(...).- A splat does not walk a
for_eachmap. Usevalues(...)or afor. dynamicexpands nested blocks. It is not a third way to create resources.count/for_eachcannot wait on a computed remote ID.
On Terraform 1.12, which statement about count and for_each on the same resource or module block is correct?
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?
You need a variable number of ingress rules on a single aws_security_group. Which Terraform 1.12 construct generates those nested blocks?