Skip to main content
RunBook Academy

TerraformIV · HCL: The Terraform Configuration LanguageProduction Terraform

Conditional Expressions and For Loops

Intermediate⏱ ~14 minbash

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

Not yet marked complete on this device.

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 when var.create_log_group is false (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 when var.replicas is 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

UseWhen
countYou 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_eachYou 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 -> lookup against a locals map.
  • Nested ternaries -> coalesce chain over flat names, or extract into a locals block.

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

  1. Removing an element from count. var.replicas = 5 -> var.replicas = 3 recreates the two tail instances. Recovery: ship a runbook, run terraform plan, confirm with the team before applying.
  2. count over a for_each style input. Changing from a list to a map without converting to for_each recreates every instance because the index order is undefined for maps. Always convert at the boundary.
  3. for_each over a list. Terraform requires a set or a map. for_each = var.list errors with “argument must be set or map”. Recovery: for_each = toset(var.list).
  4. Dynamic block label shadowing. Naming the dynamic block ingress and then inside content referencing ingress.value.from_port is correct. Naming it for_each and then trying to use for_each.value inside produces a parse error. Use a distinct name.
  5. Conditional ternary type mismatch. var.prod ? "m7i.large" : 1 returns string or number depending on the value; the provider expects one. Recovery: coerce both branches, or use a typed constant.
  6. dynamic for top-level resources. A dynamic "resource" block outside a resource body does not exist. The control flow does not apply to the resource, module, data, etc. blocks. Use for_each on the resource itself.

Recovery procedure

  1. If count was 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.
  2. If for_each lost keys, recover by re-adding the keys to the map and running terraform apply.
  3. If a dynamic block produces the wrong nested structure, expand it manually with terraform console over tomap(...) to inspect the result.

References

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

  1. Q1. Which construct creates a configurable number of resource instances keyed by a stable name?

  2. Q2. Which is the right `count` value to express "create this resource only if var.enabled is true"?

  3. Q3. Reducing `var.replicas` from 5 to 3 destroys the trailing two instances without affecting the others.

  4. Q4. What does a `dynamic` block generate?

  5. Q5. Which of these are valid inputs to `for_each`? (Select all that apply.)

  6. Q6. You want a single nested ingress block per element of `var.ingress_rules`. Which construct do you write?

  7. 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.