TerraformIV · HCL: The Terraform Configuration LanguageProduction Terraform
Conditional Expressions and For Loops
What you'll learn
- Create zero-or-more resource instances with `count` and `for_each`
- Pick `count` vs `for_each` based on whether the key is positional or named
- Express "create-or-skip" with a conditional expression inside a `count`
- Generate one nested block per item with a `dynamic` block
- Avoid the re-creation footguns that come from changing `count` keys mid-flight
Prerequisites
None — start here.
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
A resource block by default declares one managed object. Production code rarely wants exactly one; it usually wants zero, one, many, or “as many as the caller said”. The four constructs in this lesson — count, for_each, dynamic, and the conditional expression — are how you say so. Misusing them is one of the top three causes of “Terraform silently destroyed and recreated a database” incidents.
The “create-or-skip” conditional expression
The most fundamental conditional is the ternary:
condition ? value_if_true : value_if_false
It appears anywhere an expression is allowed. The most common form is gating a single attribute:
resource "aws_s3_bucket" "logs" {
bucket = "acme-logs-${var.environment}"
region = var.region
acl = var.public_logs ? "public-read" : "private"
}
variable "public_logs" {
type = bool
default = false
description = "If true, the logs bucket is public-read. Almost never what you want."
}
A condition is a boolean expression. Most production conditionals reference a single variable; nested ternary chains are a smell. If you find yourself writing a ? b : c ? d : e ? f : g, restructure into a locals block.
count — when “how many” is positional
count is a meta-argument on a resource (or module or data) block that asks for count copies of the block. count accepts a non-negative integer expression.
resource "aws_instance" "web" {
count = var.replicas # number, e.g. 3
ami = "ami-0c1b8b2a3f4e5d6c7"
instance_type = "t3.small"
availability_zone = element(var.azs, count.index)
tags = {
Name = "web-${count.index}"
}
}
The instance is aws_instance.web[0], aws_instance.web[1], aws_instance.web[2]. count.index is the integer, starting at 0. length(aws_instance.web) returns the count.
“Create this only if the caller said yes”
The standard pattern for “create-or-skip” with count:
variable "create_log_group" {
type = bool
default = false
}
resource "aws_cloudwatch_log_group" "app" {
count = var.create_log_group ? 1 : 0 # 1 instance if true, 0 instances if false
name = "/aws/${var.service_name}"
retention_in_days = 30
}
The expression var.create_log_group ? 1 : 0 is the canonical “create-or-skip” form. Use it. Note the difference between this and:
count = var.create_log_group— fails whenvar.create_log_groupisfalse(which is a bool, not an integer). Some Terraform versions coerce, but never rely on it.count = var.create_log_group ? var.replicas : 0— only valid whenvar.replicasis itself an integer.
The re-creation footgun
count.index is the resource’s positional identity. Removing a count value (var.replicas = 3 -> var.replicas = 2) renumbers every instance from index 2 upward. Terraform interprets the renumbering as destroy + recreate. The database that was index 2 is destroyed; the database that was index 1 is left alone but renumbered.
for_each — when “how many” is keyed
for_each accepts a set or a map and produces one instance per element. The instance is addressed by its key: aws_instance.web["primary"].
resource "aws_instance" "web" {
for_each = var.instances # map(string) or set(string)
ami = "ami-0c1b8b2a3f4e5d6c7"
instance_type = each.value
availability_zone = var.primary_az
tags = {
Name = each.key
}
}
variable "instances" {
type = map(string) # name -> instance_type
default = {
primary = "t3.small"
canary = "t3.nano"
}
}
each.key is the string key. each.value is the value (a string here, but for_each over map(object({...})) gives a structured value with named attributes).
for_each over a set
locals {
azs = toset(["eu-west-1a", "eu-west-1b", "eu-west-1c"])
}
resource "aws_subnet" "public" {
for_each = local.azs # set(string)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, index(sort(local.azs), each.key))
availability_zone = each.key
}
for_each over a set gives you each.key (the string) but not each.value in any meaningful sense. Sort the set first when you need a stable ordering across count.index-style computations.
for_each is safe to add and remove keys
Removing for_each["canary"] from the map removes only that one resource. The others are untouched. The re-creation footgun that haunts count does not apply to for_each at the same intensity, because instance identity is the stable string key, not a moving integer.
Decision rule: count vs for_each
| Use | When |
|---|---|
count | You genuinely want positional indexing (e.g. choosing one element per AZ in a round-robin) and you never remove or reorder elements or the resource holds no state. |
for_each | You want named or keyed instances. Use this by default for anything else. It scales to hundreds of keys, removes cleanly, and matches the way humans think about the resource (“the canary”, “the canary we deleted”). |
dynamic blocks — conditionally iterated nested blocks
A dynamic block generates one nested block per element of a collection. The result is the same HCL structure as if you had written the blocks by hand.
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
description = ingress.value.description
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
The dynamic block is only for nested blocks inside another resource. It cannot create top-level resources. For top-level resources, use a separate resource block with for_each.
The for_each of a dynamic block is a collection; content is a block body that is templated across the collection. Inside content, the special label ingress (matching the dynamic label) gives access to ingress.key, ingress.value. The label’s name is whatever you wrote after dynamic; here it is ingress.
Conditional expressions inside arguments
The conditional ternary is everywhere. Some common production shapes:
# Single attribute gated on a flag
protocol = var.encrypted ? "tls" : "tcp"
# Number that defaults to zero if the flag is off
replicas = var.enabled ? var.replicas : 0
# One of three options based on a tier variable
instance_type = var.tier == "prod" ? "m7i.large" : (
var.tier == "staging" ? "t3.medium" : "t3.nano"
)
# Map lookup with a different fallback per environment
region = lookup(var.regions, var.environment, "eu-west-1")
Two refactoring rules:
- Three or more branches ->
lookupagainst alocalsmap. - Nested ternaries ->
coalescechain over flat names, or extract into alocalsblock.
for expressions — transform a collection (do not create resources)
The for expression is for transforming values. It does not create resources; that is what for_each does.
upper_names = [for s in var.service_names : upper(s)] # list
name_by_key = { for s in var.services : s.name => s.image } # map
filtered = [for s in var.services : s if s.enabled] # filter
For-expression rule of thumb: if a for expression produces values for an attribute slot, you want a for expression. If the for expression is producing arguments or blocks for a resource creation site, you want for_each.
Production failure modes
- Removing an element from
count.var.replicas = 5 -> var.replicas = 3recreates the two tail instances. Recovery: ship a runbook, runterraform plan, confirm with the team before applying. countover afor_eachstyle input. Changing from a list to a map without converting tofor_eachrecreates every instance because the index order is undefined for maps. Always convert at the boundary.for_eachover a list. Terraform requires a set or a map.for_each = var.listerrors with “argument must be set or map”. Recovery:for_each = toset(var.list).- Dynamic block label shadowing. Naming the dynamic block
ingressand then insidecontentreferencingingress.value.from_portis correct. Naming itfor_eachand then trying to usefor_each.valueinside produces a parse error. Use a distinct name. - Conditional ternary type mismatch.
var.prod ? "m7i.large" : 1returnsstringornumberdepending on the value; the provider expects one. Recovery: coerce both branches, or use a typed constant. dynamicfor top-level resources. Adynamic "resource"block outside a resource body does not exist. The control flow does not apply to theresource,module,data, etc. blocks. Usefor_eachon the resource itself.
Recovery procedure
- If
countwas reduced and recreated stateful instances, restore from the most recent snapshot. The Terraform state still shows the resources as alive after the partial failure; align reality and state before retrying. - If
for_eachlost keys, recover by re-adding the keys to the map and runningterraform apply. - If a
dynamicblock produces the wrong nested structure, expand it manually withterraform consoleovertomap(...)to inspect the result.
References
- HashiCorp, “The
countMeta-Argument” — https://developer.hashicorp.com/terraform/language/meta-arguments/count - HashiCorp, “The
for_eachMeta-Argument” — https://developer.hashicorp.com/terraform/language/meta-arguments/for_each - HashiCorp, “Dynamic Blocks” — https://developer.hashicorp.com/terraform/language/expressions/dynamic-blocks
- HashiCorp, “Conditional Expressions” — https://developer.hashicorp.com/terraform/language/expressions/conditionals
What comes next
The next lesson is Writing Readable HCL: formatting rules, terraform fmt, terraform validate, and the discipline of writing code for the second author (not the first).
Verification
terraform fmt -check -recursive
terraform validate
terraform plan -refresh-only
For a configuration with a count:
$ terraform plan
aws_instance.web[0] will be created
aws_instance.web[1] will be created
aws_instance.web[2] will be created
For a configuration with for_each:
$ terraform plan
aws_instance.web["primary"] will be created
aws_instance.web["canary"] will be created
If the count form shows indices like [7] going up to [9] after you reduced the count, the diff will also show [7] and [8] being destroyed. Inspect the destroy list manually before approving.
Knowledge check · 7 questions
Q1. Which construct creates a configurable number of resource instances keyed by a stable name?
Q2. Which is the right `count` value to express "create this resource only if var.enabled is true"?
Q3. Reducing `var.replicas` from 5 to 3 destroys the trailing two instances without affecting the others.
Q4. What does a `dynamic` block generate?
Q5. Which of these are valid inputs to `for_each`? (Select all that apply.)
Q6. You want a single nested ingress block per element of `var.ingress_rules`. Which construct do you write?
Q7. A count-driven module is being changed from `count = 5` to `count = 3`. The diff shows two instances scheduled for destruction. What is the right action before applying?
Passing score: 75%. Answers are checked in this browser.