TerraformIV · HCL: The Terraform Configuration LanguageProduction Terraform
Functions for Production Code
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
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
| Task | Reach for | Not this |
|---|---|---|
| Default for a missing map key | lookup(map, key, default) | [for k,v in m : k == "x" ? v : ""][0] |
| Cascade multiple fallbacks | coalesce(...) | nested ternaries |
| Merge N maps | merge(m1, m2, mN) | for k,v in [...] |
| Build a map from two lists | zipmap(keys, values) | for expression with two variables |
| Build a list of pairs | flatten([for x in xs : [k, v]]) | string concatenation |
| Pick a CIDR subnet | cidrsubnet(prefix, newbits, netnum) | hand-computed string |
| Render JSON | jsonencode(value) | "${jsonencode(...)}" (don’t add the quotes) |
| Render YAML | yamlencode(value) | templatefile for trivial YAML |
| Read a config file | file(path) | data "external" with a CLI call |
| Render a template | templatefile(path, vars) | jsonencode for templated JSON |
| Date stamp | formatdate over an RFC 3339 string | string slicing |
Production failure modes
mergewith anullargument.merge(local.base, var.optional)fails with “argument must be map, got null”. The caller controlledvar.optional. Recovery:merge(local.base, try(var.optional, {}))or force a default withcoalesce(var.optional, {}).zipmapwith mismatched lengths. A subtle one when the lists come fromvar.*. Always validate with avalidationblock on the input variable.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: replaceelementwith[for k, v in map : k][n]or validate the index upstream.flattenwith multi-level nesting. Two-deepflattenis 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.timestamp()in an identity attribute. Causes an update on every plan. Solution: never put it intags,name, or any attribute a provider marks asForceNew.jsondecodeover 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.formatvs${ ... }confusion.format("%s", var.x)and"${var.x}"produce the same result for a single argument. The interpolation form is preferred for one substitution;formatis preferred for two or more.
References
- HashiCorp, “Function Reference” — https://developer.hashicorp.com/terraform/language/functions
- HashiCorp, “Type Conversion Functions” — https://developer.hashicorp.com/terraform/language/functions/type-conversion-functions
- HashiCorp, “String Functions” — https://developer.hashicorp.com/terraform/language/functions/string-functions
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
Q1. Which function returns the first non-null, non-empty argument?
Q2. What does `cidrsubnet("10.0.0.0/16", 8, 1)` return?
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.
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?
Q5. Which of these are valid uses of `jsonencode`? (Select all that apply.)
Q6. Which function renders a shell-style configuration template that is stored as a file on disk, substituting variables into it?
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.