In this episode we'll deepen our HCL skills by learning built-in functions, conditional expressions (ternary), and the count and for_each looping meta-arguments to write dynamic and efficient configurations.

After discussing State Management in episode 5 — from the vital role of the state file as a bridge between code and reality in the cloud, the problems of local state in a team environment, to the remote state solution with S3/GCS along with state locking — in this episode we'll complete the main weapon in writing dynamic Terraform configurations: expressions, functions, and loops.
So far, the configurations we've written still tend to be "static": one resource, one attribute, hardcoded values. In the real working world, static configurations like that almost never last long. Imagine having to create 5 EC2 instances at once, or 20 S3 buckets with different configurations for each environment. Writing twenty resource "aws_s3_bucket" blocks manually is clearly not a good idea — besides being tedious, it's also a source of typos and hard to maintain.
This is where HCL's advanced features come in. This capability is what differentiates Terraform from just a "tool for writing configuration" into a real infrastructure programming language. Without mastering functions, ternaries, and loops, your code will always be verbose and fragile. Let's break them down one by one.
Before discussing functions and loops, it's important to understand one foundation: expressions. In HCL, almost any value can be produced from an expression — not just literals. An expression can be:
"production", 42, truevar.environment, aws_instance.web.id, local.bucket_nameupper("hello"), length(var.list)var.env == "prod" ? "m5.large" : "t3.micro"Think of it like a spreadsheet formula: you write the formula, not the computed number, so when the input changes, the result updates automatically when plan is re-run. This is the essence of declarative IaC — we describe what we want it to become, not how to make it.
Terraform provides dozens of built-in functions grouped into several categories. In this episode we focus on the three categories most used day-to-day: string functions, collection functions, and filesystem functions.
String functions are used to manipulate text: normalizing case, joining strings, slicing, or replacing certain parts.
locals {
env = "production"
raw_name = " my-app-server "
normalized = trimspace(local.raw_name) # "my-app-server"
shout = upper(local.env) # "PRODUCTION"
whisper = lower(local.env) # "production"
title = title("devops engineer") # "Devops Engineer"
replaced = replace("dev-eu", "-", "_") # "dev_eu"
parts = split("-", "dev-eu-west-1") # ["dev", "eu", "west", "1"]
joined = join(".", local.parts) # "dev.eu.west.1"
region_short = substr("ap-southeast-1", 3, 8) # "southeast"
}Notice the combination of the two: split breaks a string into a list, and join merges a list into a string. This combination is what's most often used for data normalization — for example turning the dev-eu-west-1 environment name into the valid bucket suffix dev.eu.west.1. This matters because not every character valid in a string is actually valid for an S3 bucket name or a tag.
This category is the most used. Collections in HCL include lists (["a", "b"]) and maps ({ key = "value" }). Functions you must master:
| Function | Description | Example Result |
|---|---|---|
length(x) | Counts list/map elements or string length | length(["a", "b"]) → 2 |
merge(m1, m2) | Merges several maps into one | merge({a = 1}, {b = 2}) → {a = 1, b = 2} |
lookup(map, key, default) | Gets a map value, with a default if the key is missing | lookup({a = 1}, "b", 0) → 0 |
concat(list1, list2) | Merges several lists | concat([1], [2]) → [1, 2] |
element(list, index) | Gets the element at a specific position (cyclic) | element(["a", "b"], 3) → "a" |
keys(map) | Returns a list of all keys | keys({a = 1, b = 2}) → ["a", "b"] |
values(map) | Returns a list of all values | values({a = 1, b = 2}) → [1, 2] |
toset(list) | Converts a list to a set (removes duplicates) | toset(["a", "a", "b"]) → ["a", "b"] |
distinct(list) | Removes duplicate elements from a list | distinct([1, 1, 2]) → [1, 2] |
sort(list) | Sorts list elements | sort(["c", "a"]) → ["a", "c"] |
The lookup function deserves extra attention because it's the workhorse for providing default values to maps that may not have a specific key. A real example: determining instance_type per environment.
variable "environment" {
type = string
default = "dev"
}
locals {
instance_types = {
dev = "t3.micro"
stg = "t3.medium"
prod = "m5.large"
}
instance_type = lookup(local.instance_types, var.environment, "t3.small")
}
output "selected_instance_type" {
value = local.instance_type
}Tip
The crucial difference between lookup and direct map access local.map["key"]: direct access will error if the key doesn't exist, while lookup returns the default value you specify. In multi-environment scenarios, lookup(local.instance_types, var.environment, "t3.small") is far safer than local.instance_types[var.environment], which would blow up when there's a new environment that isn't registered yet.
Filesystem functions connect Terraform configuration to files on disk — useful for reading user data scripts, template files, or checking whether a file exists before using it.
file(path) — reads the entire file contents as a string.fileexists(path) — returns true/false whether the file exists.templatefile(path, vars) — reads a template file and fills in its template variables.The templatefile function is most often used for EC2 user data. Notice the following example:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
user_data = templatefile("${path.module}/user-data.sh.tftpl", {
app_name = var.app_name
app_port = var.app_port
})
}#!/bin/bash
set -euxo pipefail
apt-get update
apt-get install -y nginx
echo "Hello from ${app_name} on port ${app_port}" > /var/www/html/index.htmlTemplate files with the .tftpl extension let you insert variables with ${variable_name} syntax. The advantage: the bash logic stays clean and can be written multi-line, while the dynamic data is supplied from Terraform variables at apply time. This is far neater than joining giant strings with <<EOF or format operators.
When the configuration flow needs branching, HCL provides a conditional expression in the form:
condition ? true_val : false_valRead it as: "if condition is true, use true_val; otherwise, use false_val". This is equivalent to the ternary operator in other languages (for example a ? b : c in C/JS/Java). A very common real example — choosing the instance type based on the environment:
variable "environment" {
type = string
default = "dev"
}
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = var.environment == "production" ? "m5.large" : "t3.micro"
tags = {
Name = "app-server"
Env = var.environment
Managed = "terraform"
}
}Conditional expressions can also be chained (nested). But don't overdo it — if the branching is more than two levels deep, it's better to move the logic to locals for readability:
locals {
disk_size = var.environment == "production" ? 200
: var.environment == "staging" ? 100
: 50
}Warning
An important rule: both value branches of a ternary must be of the same type or at least mutually convertible. condition ? "high" : 3 will error because it tries to combine a string and a number. For different types, convert explicitly first, e.g. condition ? "high" : tostring(3).
count, for_each, and For ExpressionsThis is the heart of this episode: three mechanisms to create many resources from one block definition. Each has different use cases, and choosing wrong means big problems down the road.
count and count.index — Number-Based Loopscount accepts an integer and creates that many resources. To distinguish each instance, HCL provides count.index, which starts at 0. Example: creating 3 identical EC2 instances.
variable "instance_count" {
type = number
default = 3
}
resource "aws_instance" "web" {
count = var.instance_count
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = {
Name = "web-server-${count.index}"
}
}After apply, you'll see three instances with the addresses aws_instance.web[0], aws_instance.web[1], and aws_instance.web[2]. To access all of them at once, use the splat expression: aws_instance.web[*].id produces a list of all three IDs. For a specific one: aws_instance.web[0].id.
output "web_instance_ids" {
value = aws_instance.web[*].id
}
output "first_instance_id" {
value = aws_instance.web[0].id
}for_each and each.key / each.value — Set/Map-Based Loopsfor_each accepts a map or set of strings, and creates one resource for each element. To access the element being processed, HCL provides two special variables: each.key and each.value. When iterating over a set, each.key and each.value hold the same value; when over a map, each.key is the key and each.value is the value of that element.
Example: creating S3 buckets for several needs at once, with different regions.
locals {
buckets = {
"app-logs" = { region = "ap-southeast-1" }
"backup-data" = { region = "ap-southeast-1" }
"static-assets" = { region = "us-east-1" }
}
}
resource "aws_s3_bucket" "data" {
for_each = local.buckets
bucket = each.key
tags = {
Name = each.key
Region = each.value.region
Env = var.environment
}
}Now aws_s3_bucket.data["app-logs"] becomes a valid address, and values(aws_s3_bucket.data)[*].id produces all the bucket IDs. You can think of for_each as a way to create many resources with varied but neatly recorded configurations — unlike count, which tends to create many identical resources.
count vs for_eachTo clarify when to use which, look at the comparison of two ways of creating a set of IAM users below:
resource "aws_iam_user" "team" {
count = length(var.team_members)
name = var.team_members[count.index]
}With count, elements are accessed by numeric index; with for_each, elements are accessed by stable key. This is the philosophical difference that will later determine whether Terraform destroys your resources unexpectedly (read the Common Pitfalls section below).
[for s in var.list : upper(s)]Besides creating repeated resources, HCL can also process collections using for expressions. This is similar to list comprehension in Python. Its basic form:
[for item in list : transform(item)]Example: converting all list elements to uppercase, while also filtering out elements whose length is below the threshold.
variable "services" {
type = list(string)
default = ["api", "web", "worker", "cron"]
}
locals {
uppercase_all = [for s in var.services : upper(s)]
long_only = [for s in var.services : upper(s) if length(s) > 3]
map_result = { for s in var.services : s => upper(s) }
}
output "long_only" {
value = local.long_only # ["WORKER", "CRON"]
}For expressions can also produce maps — like map_result above — with the { for k, v in map : k => transform(v) } pattern. This capability is very useful for building intermediate data structures that will later be consumed by for_each.
1. count + a list that changes order = resources destroyed & recreated
This is the most famous trap in the Terraform ecosystem. When count takes elements from a list, each resource is tied to an index position, not the element's identity. If the list changes — for example ["budi", "siti", "agus"] becomes ["siti", "agus", "budi"] — then count.index 0 which was budi now becomes siti, and Terraform will recreate the entire set of resources. For data that is a collection or keyed, for_each is far safer because its keys are stable.
2. for_each rejects plain lists
for_each only accepts a map or set of strings. Throwing in a list directly will produce the classic error: The given "for_each" argument value is unsuitable: the "for_each" argument must be a map, or set of strings. The solution is simple — wrap it with toset(var.team_members).
3. Using each.key / each.value outside a for_each block
The each variable is only available inside resources declared with for_each. Using it on a count resource will error with the message Each object must be identified by exactly one .... Know which block uses count.index and which uses each — don't mix them.
4. Ternary with different types
As already mentioned: var.env == "prod" ? 20 : "twenty" will fail validation because the branches are type-inconsistent.
5. Hardcoding instead of leveraging lookup
Direct map access local.map[var.key] is indeed more concise, but it errors immediately when the key doesn't exist. In configurations run by many teams with different environments, lookup with a sensible default is the more defensive decision.
In this episode 6 we enriched our HCL vocabulary with three important capabilities: built-in functions (string, collection, and filesystem) to manipulate values, conditional expressions (ternary) for logic branching, and looping meta-arguments — count for number-based resources, for_each for set/map-based resources, and for expressions to process collections.
What to remember: Terraform's power doesn't lie in one perfectly written resource, but in the ability to write many resources that are consistent, parameterized, and adaptive to their environment. Functions and loops are the fuel. The more you practice combining for_each + lookup + for expressions, the faster you'll write configurations that previously took hundreds of lines in just tens of lines.
In the next episode 7 we'll discuss Resource Dependencies & Lifecycle Rules — how Terraform determines resource creation order through the dependency graph, when we should force order with depends_on, and how lifecycle rules like create_before_destroy, prevent_destroy, and ignore_changes protect your infrastructure in the real world. Stay excited!