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.
- Chapter 4: Variables, Locals, and Outputs
- 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
countby 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 tofor_eachwith 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 alocalwith a clearly-named intermediate value, or restructure the input data so the logic simplifies. - Wrong: Using
dynamicblocks 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: reservedynamicfor genuinely variable-length repeated configuration.
10. Production Tips
- 🚀 Best Practice: Whenever you catch yourself about to use
counton a list of named things, pause and convert tofor_eachwith a map instead — it’s a five-minute refactor now versus a painful production incident later. - 💡 Tip: Use
jsonencode/yamlencodefor 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 useterraform state mvto 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).
- Write a configuration using
count = 3to create threelocal_fileresources namedfile-0.txt,file-1.txt,file-2.txt(usingcount.indexin the filename). - 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.
- Refactor to
for_eachover a set of names (toset(["a", "b", "c"])), each producing a file named after its key. - 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:
- Add a variable
lifecycle_rules(list of objects:id,days,storage_class). - Add a
dynamic "lifecycle_rule"block on the bucket resource generating one rule per list item. - 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_eachaddresses resources by a stable key;countaddresses by position — preferfor_eachwhenever items have a natural name, since it avoids cascading destroy/recreate from mid-list removals.- Conditional expressions (
condition ? a : b) and thecount = condition ? 1 : 0pattern make resources and values conditional. dynamicblocks 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 }
}
18. Related Topics
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.