Learning PathsTerraform
TerraformChapter 5·INTERMEDIATE·10 min read

Expressions, Functions, and Dynamic Blocks

Built-in functions across strings, collections, and encoding; conditional and splat expressions; count vs for_each for looping over resources; and dynamic blocks for generating repeated nested configuration.

Prerequisites
  • Chapter 4: Variables, Locals, and Outputs
You'll learn to
  • Use common built-in functions confidently (string, collection, encoding, date/time)
  • Write conditional and splat expressions
  • Choose between count and for_each correctly, and explain why for_each is usually safer
  • Build a dynamic block to generate repeated nested configuration from a list or map

Expressions, Functions, and Dynamic Blocks

1. Why does this exist?

Every configuration so far in this course has been static — one bucket, one instance, hardcoded values. Real infrastructure needs to express “one of these per availability zone,” “one security group rule per port in this list,” or “skip this resource entirely in dev.” HCL’s expression language — functions, conditionals, and the looping constructs count and for_each — is what turns a static description into one that scales with input, without hand-copy-pasting resource blocks.

2. Concept

Function Categories (a sample of the most-used ones)

Category Examples What They Do
String join, split, format, lower, upper, trimspace Manipulate text values
Collection length, merge, concat, contains, flatten, distinct Manipulate lists, maps, sets
Encoding jsonencode, jsondecode, base64encode, yamlencode Convert between formats
Filesystem file, templatefile, fileexists Read local files into configuration
Date/Time timestamp, formatdate Rarely needed, mostly for naming/tagging with a build time

count vs for_each

count for_each
Input A number A map or a set of strings
Addressing By numeric index — resource[0], resource[1] By key — resource["web"], resource["api"]
Removing a middle item Shifts every subsequent index → unrelated resources get destroyed/recreated Only the removed key’s resource is affected — everything else untouched
Best for A genuinely identical set of N things (e.g., “3 replicas”) A set of distinct, named things (subnets, IAM users, security group rules)

The practical rule: default to for_each whenever the items being iterated have a natural, stable name — reach for count only when the items are truly interchangeable and only their quantity matters.

3. Architecture Diagram

flowchart TD
    subgraph CountPattern["count (index-based)"]
        C0["resource[0]"]
        C1["resource[1]"]
        C2["resource[2]"]
    end
    subgraph ForEachPattern["for_each (key-based)"]
        F1["resource[\"web\"]"]
        F2["resource[\"api\"]"]
        F3["resource[\"worker\"]"]
    end
    RemoveMiddle["Remove middle item"] -->|"count: index 1,2 shift → unrelated destroy/recreate"| CountPattern
    RemoveMiddle -->|"for_each: only that key removed"| ForEachPattern

4. Visual Explanation

Decision Tree: count or for_each?

Do the items have a natural, stable name/key?
├── Yes → use for_each (map or set)
└── No, they're just N identical things
    └── Do you ever expect to remove one from the middle?
        ├── Yes → still prefer for_each (convert to a set of indices as strings)
        └── No, truly fixed/interchangeable → count is fine
Hint: how do I convert a list to something for_each accepts?

Use toset(var.my_list) if the list has unique values, or restructure the input as a map keyed by a stable identifier if you need both a key and associated data — e.g. { web = { port = 80 }, api = { port = 8080 } }.

5. Example

variable "subnets" {
  type = map(object({
    cidr_block = string
    az         = string
  }))
  default = {
    web = { cidr_block = "10.0.1.0/24", az = "us-east-1a" }
    api = { cidr_block = "10.0.2.0/24", az = "us-east-1b" }
  }
}

resource "aws_subnet" "this" {
  for_each          = var.subnets
  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value.cidr_block
  availability_zone = each.value.az

  tags = {
    Name = each.key
  }
}

output "subnet_ids" {
  value       = { for k, s in aws_subnet.this : k => s.id }
  description = "Map of subnet name to subnet ID"
}

each.key is the map key ("web", "api"); each.value is that key’s associated object. Removing api from the var.subnets map only affects aws_subnet.this["api"]web’s subnet is completely untouched in the plan.

6. Production Example

Dynamic blocks generating security group rules from a variable:

variable "ingress_rules" {
  type = list(object({
    port        = number
    protocol    = string
    cidr_blocks = list(string)
  }))
  default = [
    { port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] },
    { port = 22, protocol = "tcp", cidr_blocks = ["10.0.0.0/8"] },
  ]
}

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.port
      to_port     = ingress.value.port
      protocol    = ingress.value.protocol
      cidr_blocks = ingress.value.cidr_blocks
    }
  }
}

Without dynamic, adding a fourth ingress rule would mean hand-writing another nested ingress { ... } block — with it, the rule count is entirely driven by var.ingress_rules’ length.

7. Code Walkthrough — Conditionals and Splat

# Conditional expression: condition ? true_value : false_value
resource "aws_instance" "web" {
  instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"
}

# Conditional resource creation (the "count as a boolean" pattern)
resource "aws_eip" "web" {
  count    = var.enable_static_ip ? 1 : 0
  instance = aws_instance.web.id
}

# Splat expression: collect an attribute across every instance from for_each/count
output "all_subnet_cidrs" {
  value = values(aws_subnet.this)[*].cidr_block
}

# for expression: transform a collection into another shape
output "subnet_name_to_az" {
  value = { for k, s in aws_subnet.this : k => s.availability_zone }
}

count = var.enable_static_ip ? 1 : 0 is a common, slightly informal pattern for making an entire resource conditional — 0 means “don’t create this at all.” It works, but note it reintroduces count’s index-addressing tradeoffs discussed above; for a single optional resource this is usually fine since there’s only ever one possible index.

8. Behind the Scenes

Functions and expressions are evaluated by Terraform’s HCL evaluation engine at plan time, using the current known values of every variable, local, resource attribute, and data source result available at that point in the graph. for_each and count are special — they’re evaluated before the resource’s own arguments, since Terraform needs to know how many instances of a resource to plan before it can plan each one’s individual arguments. This is also why for_each’s input generally can’t depend on a value only known after apply (like an auto-generated ID from a resource created in the same apply) — the loop’s shape must be knowable at plan time.

9. Common Mistakes

  • Wrong: Reaching for count by default out of habit, even when items have natural names. Why: any removal from the middle of the list cascades into unrelated destroy/recreate operations, as this chapter’s practical scenario demonstrates. Correct: default to for_each with a map or set unless the items are truly interchangeable.
  • Wrong: Writing deeply nested conditional expressions to express complex logic (a ? (b ? c : d) : e). Why: unreadable, hard to review, and hard to debug when it produces an unexpected plan. Correct: prefer a local with a clearly-named intermediate value, or restructure the input data so the logic simplifies.
  • Wrong: Using dynamic blocks for a fixed, small, unlikely-to-change set of nested blocks (e.g., exactly two hardcoded ingress rules that will never change). Why: adds indirection for no real benefit when the content is static. Correct: reserve dynamic for genuinely variable-length repeated configuration.

10. Production Tips

  • 🚀 Best Practice: Whenever you catch yourself about to use count on a list of named things, pause and convert to for_each with a map instead — it’s a five-minute refactor now versus a painful production incident later.
  • 💡 Tip: Use jsonencode/yamlencode for any argument that expects a JSON or YAML string (like an IAM policy document or a Kubernetes manifest) instead of hand-writing escaped string literals.
  • ⚠️ Warning: for_each’s map keys become part of each resource’s permanent address in state — renaming a key is equivalent to destroying and recreating that resource unless you also use terraform state mv to rename it in state first.

11. Debugging

Symptom Likely Cause Fix
Error: Invalid for_each argument Passed a list directly where a map or set was expected Wrap it in toset(...), or restructure as a map with stable keys
Unexpected destroy/recreate after removing one list item Used count with index-based addressing Migrate to for_each with a map keyed by a stable name
Error: Invalid count argument mentioning “depends on resource attributes that cannot be determined until apply” count/for_each’s input depends on a value only known after apply Restructure so the loop’s shape is knowable at plan time — often means separating the resource that produces the unknown value into an earlier apply, or using a data source instead
Dynamic block generates the wrong number of nested blocks for_each on the dynamic block references the wrong variable, or the variable’s shape changed Print the variable via an output temporarily to confirm its actual shape before debugging further

12. Interview Questions

Q — Explain, concretely, why for_each is generally considered safer than count for a list of named resources. count addresses resources by numeric index, so any change to the list’s length or order shifts every subsequent index — Terraform then sees those shifted-index resources as changed, and plans to destroy and recreate them even though nothing meaningfully changed about them. for_each addresses resources by a stable key (map key or set value), so adding or removing one item only affects that specific key’s resource, leaving everything else in the plan untouched.

Q — What’s the difference between a splat expression and a for expression? A splat expression (resource[*].attribute) is a shorthand for collecting one attribute across every instance of a count/for_each resource into a list. A for expression is more general — it can transform a collection into a differently-shaped collection (e.g., a map instead of a list, or with keys/values computed from an expression), which a splat alone can’t do.

Q — When would a dynamic block be the wrong tool, even though it technically works? When the nested repeated content is genuinely fixed and small — hardcoding two ingress rules directly is more readable than a dynamic block iterating over a two-item variable that will never realistically change. Dynamic blocks earn their complexity when the count and content of repetitions are actually meant to vary by caller/environment.

13. Hands-on Lab

Goal: refactor a count-based configuration to for_each, observing the plan difference, using local_file (no cloud account needed).

  1. Write a configuration using count = 3 to create three local_file resources named file-0.txt, file-1.txt, file-2.txt (using count.index in the filename).
  2. Apply it, then edit the list backing the count (if you parameterize it) to remove the middle item — observe the plan wants to touch files it shouldn’t need to.
  3. Refactor to for_each over a set of names (toset(["a", "b", "c"])), each producing a file named after its key.
  4. Remove one key from the set and re-plan — confirm only that one file is affected.
Solution

Step 2 should show local_file.this[1] being destroyed and local_file.this[2]'s content changing, even though conceptually only one file should have disappeared — this is the index-shift problem made concrete. Step 4 should show exactly one file removed and nothing else touched.

14. Mini Project

Add dynamic tagging to the Chapter 4 mini project. Take the parameterized S3 bucket configuration:

  1. Add a variable lifecycle_rules (list of objects: id, days, storage_class).
  2. Add a dynamic "lifecycle_rule" block on the bucket resource generating one rule per list item.
  3. Test with zero, one, and three lifecycle rules, confirming the generated configuration scales correctly each time.

16. Summary

  • Functions (string, collection, encoding, filesystem) let expressions transform values instead of requiring every value to be a literal.
  • for_each addresses resources by a stable key; count addresses by position — prefer for_each whenever items have a natural name, since it avoids cascading destroy/recreate from mid-list removals.
  • Conditional expressions (condition ? a : b) and the count = condition ? 1 : 0 pattern make resources and values conditional.
  • dynamic blocks generate repeated nested configuration from a list/map — reserve them for genuinely variable-length repetition, not fixed small blocks.

17. Cheat Sheet

# for_each over a map
resource "type" "name" {
  for_each = var.my_map
  key      = each.key
  value    = each.value
}

# Conditional
value = var.flag ? "a" : "b"

# Splat
output "ids" { value = resource.name[*].id }

# for expression
output "map" { value = { for k, v in var.x : k => v.y } }

# dynamic block
dynamic "block_name" {
  for_each = var.list
  content { arg = block_name.value.field }
}

Next: Chapter 6 covers state management — where all the resources created by count/for_each are actually tracked, and how removing or renaming a for_each key interacts with state directly. Chapter 9’s modules chapter builds on these patterns for reusable, parameterized infrastructure.

🔒 Locked
Sign in and unlock the full syllabus to keep reading.
Sign in to continue
← Variables, Locals, and OutputsState Management →