Built-in Functions & Dynamic Expressions

Built-in Functions & Dynamic Expressions
HCL Is Not a Programming Language — But It’s Surprisingly Capable

You can’t write arbitrary loops, define custom functions, or implement object-oriented patterns in HCL. It’s a configuration language, not a general-purpose one. But it ships with a rich library of built-in functions and expression types that handle most real-world infrastructure needs — from string manipulation to collection transformation to template rendering.

Understanding what’s available prevents two common antipatterns: (1) writing ugly null_resource workarounds to compute values externally, and (2) duplicating configuration that could be generated dynamically. The built-in functions exist precisely so your HCL can express complex infrastructure relationships without reaching for external scripts.

String Functions — The Most Common Category

String manipulation is everywhere in infrastructure code: constructing resource names, building ARNs, formatting tags, generating config files.

# Interpolation
name = "web-${var.environment}"

# Case conversion
upper("hello")                     # "HELLO"
lower("PROD")                      # "prod"

# Formatted strings (printf-style)
format("web-%s-%02d", var.env, count.index)   # "web-prod-00"

# Join and split — converting between lists and delimited strings
join(",", ["a", "b", "c"])         # "a,b,c"
split(",", "a,b,c")                # ["a", "b", "c"]

# Whitespace handling
trimspace("  hello  ")             # "hello"

# Pattern replacement
replace("hello world", " ", "-")   # "hello-world"

# Membership testing
startswith("terraform", "terra")   # true
endswith("main.tf", ".tf")         # true

Why these matter: resource naming conventions in organizations typically follow patterns like {project}-{environment}-{service}-{region}. Functions like join(), format(), and lower() let you construct these names programmatically from variables, ensuring consistency across hundreds of resources without manual string assembly.

Numeric Functions
min(5, 3, 8)                       # 3
max(5, 3, 8)                       # 8
abs(-5)                            # 5
ceil(1.2)                          # 2 (round up)
floor(1.8)                         # 1 (round down)

# Range — generates sequences (often used with count or for)
range(3)                           # [0, 1, 2]
range(1, 5)                        # [1, 2, 3, 4]
range(0, 10, 2)                    # [0, 2, 4, 6, 8]

ceil() and floor() are particularly useful for CIDR math and instance count calculations. If you need “at least 2 instances per AZ across 3 AZs, totaling no more than 10,” min() and ceil() help express that constraint.

Collection Functions — The Workhorses

These appear constantly in real configs. The ones worth knowing cold:

# Length — works on lists, maps, and strings
length(["a", "b", "c"])            # 3
length("hello")                    # 5
length({a=1, b=2})                 # 2

# Merge maps — combining tag sets, config fragments
merge({a=1}, {b=2}, {c=3})         # {a=1, b=2, c=3}
merge({a=1}, {a=99, b=2})          # {a=99, b=2} — last map wins on conflict

# Keys and values — extract from maps
keys({a=1, b=2})                   # ["a", "b"]
values({a=1, b=2})                 # [1, 2]

# Lookup with fallback — safe map access
lookup({env="prod"}, "env", "dev")        # "prod"
lookup({env="prod"}, "region", "us-east-1") # "us-east-1" (default)

# Flatten nested lists — critical for multi-level module outputs
flatten([[1,2], [3,4]])            # [1, 2, 3, 4]

# Distinct — deduplicate a list
distinct(["a", "b", "a", "c"])     # ["a", "b", "c"]

# Compact — remove empty strings and nulls
compact(["a", "", "b", null])      # ["a", "b"]

# Contains — check membership
contains(["dev", "prod"], "prod")  # true

merge() deserves special attention. It’s the function behind the common tagging pattern: merge(local.common_tags, { Role = "web" }). When maps have conflicting keys, the rightmost map wins — so resource-specific tags override common ones when there’s a conflict.

flatten() solves a problem you’ll encounter with modules: when a module returns a list of lists (like subnet IDs grouped by AZ), flatten() collapses them into a single flat list. Without it, you’d need nested loops to work with the data.

templatefile() — Generating Config Files from Templates
# user-data.sh.tpl
#!/bin/bash
echo "Environment: ${environment}"
echo "App name: ${app_name}"
apt-get install -y ${package_name}
resource "aws_instance" "web" {
  user_data = templatefile("${path.module}/user-data.sh.tpl", {
    environment  = var.environment
    app_name     = var.app_name
    package_name = "nginx"
  })
}

templatefile() reads a local file and renders it with provided variables. The template uses standard interpolation syntax (${variable_name}). This is the right way to generate startup scripts, config files, IAM policy documents, or any structured text that depends on Terraform values.

Why not inline the script? Because multi-line strings in HCL get unwieldy fast. A 50-line user data script embedded in a resource block makes the config unreadable. A template file keeps the infrastructure code clean and the script maintainable in its own file.

Conditional Expressions — The Ternary Operator
# condition ? value_if_true : value_if_false
instance_type = var.environment == "prod" ? "t3.xlarge" : "t3.micro"
multi_az      = var.environment == "prod" ? true : false
count         = var.create_instance ? 1 : 0

Conditionals work anywhere an expression is valid. The most impactful pattern: count = condition ? 1 : 0 for conditionally creating resources. This lets you toggle entire resource blocks on and off without removing them from the config.

Conditionals should stay simple. If you find yourself nesting ternaries (a ? b : (c ? d : e)), that’s a sign you need a local with a lookup map instead.

for Expressions — Transforming Collections

Transform or filter a list or map into a new collection:

# Transform a list — uppercase all elements
[for s in ["a", "b", "c"] : upper(s)]
# ["A", "B", "C"]

# Filter a list — keep only elements matching a condition
[for s in ["alpha", "beta", "prod"] : s if startswith(s, "p")]
# ["prod"]

# Transform a list into a map — list of objects to map keyed by name
{for s in var.servers : s.name => s.ip}
# { "web-1" = "10.0.1.1", "web-2" = "10.0.1.2" }

# Practical: extract public subnet IDs from a for_each map
output "public_subnet_ids" {
  value = [for k, v in aws_subnet.this : v.id if v.map_public_ip_on_launch]
}

The for expression is HCL’s functional programming tool. It replaces what would be a loop in an imperative language. Square brackets [...] produce a list. Curly braces {...} produce a map. The if clause filters elements. This is how you extract, transform, and reshape data flowing between modules.

dynamic Blocks — DRY Nested Configuration

Some resources have repeating nested blocks — security group rules, IAM policy statements, load balancer listener rules. dynamic blocks generate them from a variable:

variable "ingress_rules" {
  type = list(object({
    from_port = number
    to_port   = number
    protocol  = string
    cidr      = string
  }))
  default = [
    { from_port = 80,  to_port = 80,  protocol = "tcp", cidr = "0.0.0.0/0" },
    { from_port = 443, to_port = 443, protocol = "tcp", cidr = "0.0.0.0/0" },
  ]
}

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]
    }
  }
}

Without dynamic, you’d hardcode each ingress block. With it, the caller passes a list and the security group generates the right number of rules automatically. Adding a new rule means adding one entry to the list — no changes to the resource block.

Use dynamic for repeating nested blocks. Don’t use it for top-level resource blocks — that’s what for_each and count are for.

terraform console — The REPL for Testing Expressions

Before embedding a complex expression in your config, test it interactively:

$ terraform console

> join("-", ["web", "prod", "us-east-1"])
"web-prod-us-east-1"

> [for i in range(3) : "subnet-${i}"]
["subnet-0", "subnet-1", "subnet-2"]

> merge({a=1}, {b=2})
{ "a" = 1, "b" = 2 }

The console loads your current state and config, so you can test expressions against real resource attributes and variable values. This is dramatically faster than debugging expressions through the plan-apply cycle.

Key Takeaways

HCL functions are pure — they have no side effects and can’t call external systems. templatefile() reads a local file. file() reads a local file. You can’t make HTTP calls, query databases, or interact with external APIs from within HCL functions. That boundary is deliberate — it keeps Terraform’s planning phase predictable and deterministic.

Know merge(), lookup(), flatten(), join(), split(), for expressions, and templatefile() thoroughly. These appear in virtually every non-trivial Terraform config. merge() for tags, lookup() for environment-specific values, flatten() for nested module outputs, for for collection transformation, templatefile() for generated config files.

The dynamic block is powerful but can reduce readability if overused. A security group with 3 static ingress blocks is more readable than a dynamic block iterating over a list. Use dynamic when the number of blocks is truly variable — driven by caller input or computed from data.