Explore OpenTofu's built-in functions for strings, collections, and filesystem operations, then master the count, for_each, depends_on, and lifecycle meta-arguments, along with for expressions and ternary conditionals for writing concise, dynamic, and expressive HCL.

In the previous episode 7 we covered dynamic provider iteration — loading many provider instances at once with for_each for multi-region and multi-account setups. We already saw how one small map can replace dozens of HCL lines. Today we level up again: unpacking OpenTofu's expression language that makes HCL feel like a real programming language.
Engineers often get stuck writing configuration statically and repetitively — hardcoding subnet lists, copying resource blocks one by one, or writing if-then conditions rigidly. Yet OpenTofu provides hundreds of built-in functions, iteration meta-arguments, and conditional expressions that can drastically cut the amount of code.
In this episode we cover four pillars of OpenTofu expressions: built-in functions for strings, collections, and the filesystem; the count, for_each, and depends_on meta-arguments; lifecycle rules; and for expressions and ternary conditionals.
Built-in functions in OpenTofu work like in programming languages — they process values and return results. Broadly, there are three families you'll use most:
| Family | Example Functions | Use Cases |
|---|---|---|
| String | format, join, split, replace, upper | Text manipulation and resource name construction |
| Collection | length, lookup, merge, flatten, distinct, setunion | Combining, filtering, and tidying data |
| Filesystem | file, templatefile, yamldecode, jsondecode | Reading configuration from external files |
Here's an example of usage in a single locals block:
locals {
env = "staging"
bucket = join("-", ["app", local.env, "assets"])
subnets = split(",", var.subnet_list)
merged = merge(var.default_tags, { env = local.env })
regions = distinct(var.regions)
db_user = lookup(var.db_config, "user", "admin")
yaml_conf = yamldecode(file("config.yaml"))
safe_value = try(coalesce(var.name, local.env), "default")
}join and split build and break apart strings with a separator.merge combines multiple maps — very useful for keeping tags consistent.distinct removes duplicates from a list.lookup fetches a value from a map with a default when the key is missing.file and yamldecode read configuration files from disk and parse them.try and coalesce handle empty values safely without errors.Tip
Explore the full function list anytime by typing tofu console and trying expressions like join("-", ["a", "b"]) right in the prompt. It's the fastest playground for testing function behavior before writing it into code.
count and for_each Meta-ArgumentsThese two meta-arguments are the backbone of iteration in OpenTofu. count creates resources based on a number and accesses them via count.index; for_each creates resources based on map or set items and accesses them via each.key and each.value.
resource "aws_instance" "web" {
count = 3
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = {
Name = "web-${count.index}"
}
}resource "aws_iam_user" "developers" {
for_each = toset(["budi", "sari", "dewi"])
name = each.key
tags = {
Role = each.key == "budi" ? "admin" : "dev"
}
}Notice the Role line above: it uses a ternary — if each.key equals budi, the value is admin, otherwise dev. That's the classic pattern: iteration to spread out, ternary to customize.
Warning
Use for_each for collections with unique identities (user names, map keys), and count only for truly identical collections. Removing one item from the middle of a count list can shift indices and trigger unwanted resource recreates — for_each is far safer because keys are explicit.
depends_on Meta-ArgumentOpenTofu builds the dependency graph automatically from references between resources. depends_on is only needed when execution order can't be inferred from references — for example, a configuration that needs another resource to finish first without a direct reference:
resource "aws_instance" "app" {
depends_on = [aws_security_group.main]
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
}Note
depends_on should be a last resort. Too many depends_on makes the graph rigid and slow, and hides dependencies that could be expressed through ordinary value references. Let OpenTofu do what it can already infer on its own.
The lifecycle meta-argument gives you control over when and how OpenTofu recreates resources. The three most-used rules:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
lifecycle {
create_before_destroy = true
prevent_destroy = false
ignore_changes = [tags]
}
}create_before_destroy creates the new resource before deleting the old one — a must for minimizing downtime.prevent_destroy blocks tofu destroy — a guard for critical resources like databases.ignore_changes ignores attributes that frequently change outside IaC, such as tags modified in the console.Warning
prevent_destroy isn't absolute anti-vandalism: it only refuses a resource being removed from the code, but doesn't prevent destroy caused by definition changes. For the most critical resources, combine it with state encryption and backups. To delete a protected resource you must use tofu plan -destroy after removing the prevent_destroy line from the code.
A for expression produces a new collection from another collection — a sort of map or filter from programming languages. Splat [*] is a shortcut to grab one attribute from all instances. Ternary condition ? if_true : if_false picks a value based on a condition:
locals {
sequence = [for i in range(3) : "web-${i}"]
filtered = [for name in var.names : upper(name) if name != "skip"]
tag_map = { for k, v in var.tags : k => upper(v) }
all_ips = aws_instance.web[*].private_ip
tier = var.environment == "production" ? "m5.large" : "t3.micro"
}sequence builds a list from a range.filtered transforms and filters at once with an if clause.tag_map builds a map with a k => v expression.all_ips uses splat to collect private_ip from all instances.tier picks the instance type based on environment — a ternary here replaces an entire conditional block.Combine all of it with built-in functions, and you can express almost any configuration logic without leaving HCL.
In episode 8 we mastered OpenTofu's expression language:
join, merge, lookup to file and yamldecode.count and for_each for resource iteration, with count.index and each.key / each.value.depends_on for explicit dependencies OpenTofu can't infer.lifecycle rules — create_before_destroy, prevent_destroy, and ignore_changes.With these expressions under your belt, your HCL will be far more dynamic. In the next episode, episode 9, we'll cover OpenTofu State Manipulation & Import Workflows — importing existing infrastructure via the import block and tofu import, then refactoring without destroy using the moved block and tofu state mv. See you there!