Skip to main content
RunBook Academy

TerraformIV · HCL: The Terraform Configuration LanguageProduction Terraform

Functions for Production Code

Intermediate⏱ ~14 minbash

What you'll learn

  • Choose the right built-in function for merging, defaulting, joining, and transforming collections
  • Apply `merge`, `lookup`, `coalesce`, and `coalescelist` without losing production type-safety
  • Encode and decode JSON and YAML payloads across the Terraform boundary
  • Compute subnet and host addresses with `cidrsubnet` and `cidrhost`
  • Format strings and timestamps for tags, IAM policies, and resource naming

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

Terraform ships with around 200 built-in functions. You will use about twenty of them in a typical production codebase. This lesson covers the twenty. Outside them, the next check is “is there a more direct way to express this in HCL?” — most non-trivial function chains are a sign that a dynamic block, a for expression, or a structural input type would do the job with less risk.

How function calls work in practice

A call is name(arg1, arg2, ...). Arguments are positional. There is no keyword argument form. Terraform evaluates calls eagerly during the configuration walk — before the provider API is contacted. A failed function produces a plan-time error pointing at the offending file and line.

The result of a function call is a value. It has a type. Coerce it with the type-conversion family (tonumber, tostring, tolist, toset, tomap) if the next consumer requires a different type.

terraform console <<< 'merge({a="1"}, {b="2"})'
# { "a" = "1" "b" = "2" }

That works without any state. Use the console for fast, side-effect-free testing of every new function call you add.

Merging collections

merge(maps...) — merge any number of maps

locals {
  common_tags = {
    Owner   = "platform@example.com"
    Managed = "terraform"
  }

  env_tags = {
    Environment = var.environment
  }

  extra_tags = var.team_tags # caller-provided
}

# Result: union of all three, with later maps winning on key conflict
all_tags = merge(local.common_tags, local.env_tags, local.extra_tags)

merge returns a single map. When two input maps share a key, the right-hand map wins. Production tip: keep merge order predictable by sourcing from local.* blocks, never from raw var.* calls scattered through the file.

concat(lists...) — concatenate lists and tuples

all_azs = concat(var.extra_azs, ["eu-west-1a", "eu-west-1b", "eu-west-1c"])

Sets are not accepted directly; flatten first if needed.

flatten(nested_lists) — flatten one level

all_subnets = flatten([
  for vpc in var.vpcs : vpc.public_subnets
])

flatten only goes one level deep. Two-level nesting requires multiple flatten calls or a refactor into a flatmerge-style helper.

Defaulting and missing-key handling

lookup(map, key, default) — give a default for a missing key

region = lookup(var.region_map, var.environment, "eu-west-1")

If var.environment is in var.region_map, return its value. Otherwise return "eu-west-1". The default must match the value type expected downstream; null defaults are usually a mistake.

coalesce(args...) — first non-null, non-empty value

owner = coalesce(var.team, var.department, "platform@example.com")

coalesce returns the first argument that is not null and not empty. Use it where callers might pass null to mean “use the fallback”. The fallback must be the last argument.

coalescelist(lists...) — first non-empty list

cidr_blocks = coalescelist(local.computed_cidrs, var.fallback_cidrs, [])

Empty list is treated as missing. Use coalescelist rather than coalesce when the value is specifically a list.

Element selection and joining

element(list, n) — pick the nth element (with wrap-around)

availability_zone = element(var.azs, count.index)

element wraps. element(["a","b","c"], 5) returns "a" because the index is reduced modulo the length. This is occasionally useful but is also the source of a class of bugs where an out-of-range index silently picks the wrong region.

zipmap(keys_list, values_list) — build a map from two equal-length lists

ports_by_service = zipmap(var.service_names, var.service_ports)
# ports_by_service["api"] == 8080

The two lists must have identical lengths. Otherwise zipmap errors at plan time.

join(separator, list) — join a list of strings

iam_action_csv = join(",", var.iam_actions)

join produces a single string. Useful for IAM, CSV ingestion, and arbitrary text formatting.

Encoding across the boundary

jsonencode(value) and jsondecode(string)

policy = jsonencode({
  Version = "2012-10-17"
  Statement = [{
    Effect   = "Allow"
    Action   = var.iam_actions
    Resource = "*"
  }]
})

decoded = jsondecode(file("${path.module}/policy.json"))

jsonencode accepts anything encodable: primitives, lists, maps, and nested combinations. jsondecode requires valid JSON; a malformed file fails the plan with the JSON parser’s line number. Production pattern: store JSON/YAML inputs in locals blocks computed from jsondecode(file(...)) so the file is read once.

yamlencode(value) and yamldecode(string)

Same pattern. Most teams pick JSON for cross-tooling compatibility; YAML is friendlier to hand-edit and supports comments.

Templating

templatefile(path, vars) — render a file with variables

template = templatefile("${path.module}/templates/user_data.sh.tftpl", {
  region      = var.region
  log_level   = "info"
  environment = var.environment
})

The template file is a regular file with Terraform’s ${ ... } interpolation syntax, not Jinja’s {{ ... }}. templatefile is the right way to inject variables into a user_data script or any other hand-written text.

Date and time

timestamp() — RFC 3339 of “now”

created_at = timestamp()
# 2026-08-13T09:12:34Z

timestamps() returns the same value within a single plan; each new plan gets a fresh value. Use in a tag or another identity attribute and Terraform will update the resource on every plan. Rule: never use timestamp() in an attribute the provider uses for resource identity.

formatdate(format, time) — RFC 3339 to custom format

log_file = formatdate("YYYY-MM-DD", "2026-08-13T09:12:34Z")

formatdate interprets the second argument as RFC 3339. The format tokens are the same as date would expect on a POSIX shell: YYYY, MM, DD, hh, mm, ss, and a few others.

timeadd(duration, timestamp) — add a duration

expires_at = timeadd(timestamp(), "24h")
# 24 hours from now

Useful for IAM policy expiry, certificate rotation reminders, and tag values that must look real.

Networking: CIDR arithmetic

cidrsubnet(prefix, newbits, netnum)

subnet = cidrsubnet("10.0.0.0/16", 8, 1)
# "10.0.1.0/24"

cidrsubnet partitions a network into 2**newbits equal-sized subnets. Use it to compute subnets across availability zones from a single declared VPC CIDR.

cidrhost(network, hostnum) — the hostnum-th host

gateway = cidrhost("10.0.1.0/24", 1)
# "10.0.1.1"

cidrhost is rarely the right answer for production. The gateway is conventionally the .1 address, but most teams reserve a few addresses for the network, the gateway, and the broadcast. Use it where the answer is genuinely constant (an IP you reserve for an internal service, for example) and document the assumption.

cidrnetmask(network) — the mask as a string

mask = cidrnetmask("10.0.1.0/24") # "255.255.255.0"

Rarely useful in practice; included for completeness.

Format strings

format(spec, args...) — printf-style

label = format("%s/%s", var.environment, var.service)
# "prod/api"

format is the right tool when you want printf-style substitution rather than ${ ... } interpolation. The format spec follows Go’s fmt rules (not POSIX printf): %s, %d, %v, %T, %f, %b, etc.

Per-task decision map

TaskReach forNot this
Default for a missing map keylookup(map, key, default)[for k,v in m : k == "x" ? v : ""][0]
Cascade multiple fallbackscoalesce(...)nested ternaries
Merge N mapsmerge(m1, m2, mN)for k,v in [...]
Build a map from two listszipmap(keys, values)for expression with two variables
Build a list of pairsflatten([for x in xs : [k, v]])string concatenation
Pick a CIDR subnetcidrsubnet(prefix, newbits, netnum)hand-computed string
Render JSONjsonencode(value)"${jsonencode(...)}" (don’t add the quotes)
Render YAMLyamlencode(value)templatefile for trivial YAML
Read a config filefile(path)data "external" with a CLI call
Render a templatetemplatefile(path, vars)jsonencode for templated JSON
Date stampformatdate over an RFC 3339 stringstring slicing

Production failure modes

  1. merge with a null argument. merge(local.base, var.optional) fails with “argument must be map, got null”. The caller controlled var.optional. Recovery: merge(local.base, try(var.optional, {})) or force a default with coalesce(var.optional, {}).
  2. zipmap with mismatched lengths. A subtle one when the lists come from var.*. Always validate with a validation block on the input variable.
  3. element(list, n) wrapping past the end. element(["a","b","c"], 7) returns "b" (7 mod 3 = 1). This makes over-large indexes an obvious-looking failure. Recovery: replace element with [for k, v in map : k][n] or validate the index upstream.
  4. flatten with multi-level nesting. Two-deep flatten is a smell; restructure the input shape first. variable "vpcs" { type = list(object({ subnets = list(object({...})) })) } — if a downstream consumer needs a flat list, supply it explicitly.
  5. timestamp() in an identity attribute. Causes an update on every plan. Solution: never put it in tags, name, or any attribute a provider marks as ForceNew.
  6. jsondecode over an invalid file. Failure happens at plan time with a confusing parser error. Recovery: validate the file with a separate lint step in CI before running Terraform.
  7. format vs ${ ... } confusion. format("%s", var.x) and "${var.x}" produce the same result for a single argument. The interpolation form is preferred for one substitution; format is preferred for two or more.

References

What comes next

The next lesson is Conditional Expressions and For Loops: count, for_each, dynamic blocks, and conditional expressions — the constructs that turn one resource block into many or skip them entirely.

Verification

terraform console <<< 'merge({a="1"}, {b="2"})'
terraform console <<< 'cidrsubnet("10.0.0.0/16", 8, 1)'
terraform console <<< 'jsonencode({a=1, b="two"})'
terraform validate

Expected:

$ terraform console <<< 'jsonencode({a=1, b="two"})'
"{\"a\":1,\"b\":\"two\"}"

A different output suggests a Terraform version that disagrees with the doc references at the bottom of this file. Pin required_version against the version that produced the output you read.

Knowledge check · 7 questions

  1. Q1. Which function returns the first non-null, non-empty argument?

  2. Q2. What does `cidrsubnet("10.0.0.0/16", 8, 1)` return?

  3. Q3. Because `timestamp()` returns a fresh value on every plan, placing it in a provider-tracked attribute such as `tags` produces an update on every plan.

  4. Q4. You have `var.service_names = ["api","worker"]` and `var.service_ports = [8080, 9090]`. What is the right way to build a `ports_by_service` map?

  5. Q5. Which of these are valid uses of `jsonencode`? (Select all that apply.)

  6. Q6. Which function renders a shell-style configuration template that is stored as a file on disk, substituting variables into it?

  7. Q7. You write `merge(local.base, var.optional)` and the plan fails because the caller passes `null` for `var.optional`. Which is the right fix?

Passing score: 75%. Answers are checked in this browser.