Terraform expressions can call built-in functions: name(arg1, arg2). You cannot write your own functions in HCL; when a calculation gets complex, move it into locals with clear names, or use a provider-defined function (Terraform 1.8+). Try any function in terraform console before you use it.
flowchart LR IN["Input: variables, files"] --> S["String: format, join, replace"] IN --> C["Collection: merge, flatten, lookup"] IN --> F["Files: file, templatefile"] S --> L["locals"] C --> L F --> L L --> E["Encoding: jsonencode, yamlencode"] E --> R["resource arguments"] %% step IN: Raw input arrives as variables, data sources and files in the module folder. %% step S: String functions build names, labels and URLs from that input. %% step C: Collection functions reshape lists and maps, for example merging default and custom tags. %% step F: File functions read files relative to path.module and render templates with variables. %% step L: Keep the result in locals with readable names instead of one long nested call. %% step E: Encoding functions turn HCL values into the JSON or YAML documents that many arguments expect. %% step R: The final value is passed to resource arguments.
The families you use every day
| Family | Functions | Example → result |
|---|---|---|
| String | format, join, split, replace, lower, upper, trimspace, substr, regex, startswith |
format("shop-%s-%02d", "dev", 3) → "shop-dev-03" |
| Collection | length, merge, concat, flatten, keys, values, lookup, contains, distinct, zipmap, setproduct, element, slice |
merge({a=1}, {b=2}) → {a=1, b=2} |
| Numeric | min, max, ceil, floor, abs, pow |
max(2, var.replicas) |
| Encoding | jsonencode, jsondecode, yamlencode, yamldecode, base64encode, csvdecode |
jsonencode({port = 80}) → "{\"port\":80}" |
| Files | file, templatefile, fileset, filebase64, fileexists |
file("${path.module}/init.sh") |
| IP network | cidrsubnet, cidrhost, cidrnetmask, cidrsubnets |
cidrsubnet("10.0.0.0/16", 8, 2) → "10.0.2.0/24" |
| Type conversion | tostring, tonumber, tolist, toset, tomap, try, can, type |
try(var.cfg.port, 8080) |
| Date and time | timestamp, formatdate, timeadd, plantimestamp |
formatdate("YYYY-MM-DD", plantimestamp()) |
| Hash and crypto | sha256, md5, filesha256, uuid, bcrypt |
filesha256("app.zip") |
Patterns worth knowing
locals {
tags = merge(var.default_tags, var.extra_tags) # later maps win
subnets = [for i in range(3) : cidrsubnet(var.cidr, 8, i)] # 10.0.0.0/24, 10.0.1.0/24, ...
pairs = setproduct(var.envs, var.apps) # every env x app
app_cfg = jsonencode({ db = var.db_host, debug = var.debug })
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
hostname = var.name
packages = var.packages
})
port = try(var.settings.port, 8080) # fallback if missing
}
templatefile reads a template file and fills ${...} and %{ for } directives from the map you pass; it replaces the old template_file data source. Use path.module so the path works no matter where Terraform runs.
Functions that bite
| Function | Trap |
|---|---|
timestamp() |
A new value on every run, so the resource shows a change on every plan. Use plantimestamp() or time_static |
uuid() |
Same problem: new value each run |
file() |
Reads at plan time from disk; it cannot read files that a resource creates during apply |
lookup(map, key) |
Without a default it errors on a missing key; prefer lookup(map, key, default) or try() |
element(list, i) |
Wraps around past the end instead of failing |
Provider-defined functions
Providers can ship functions, called as provider::<name>::<function>(...), for example provider::aws::arn_parse(var.role_arn). The provider must be listed in required_providers.
Traps: functions run during plan, so they cannot see values that are only known after apply ((known after apply) flows through instead); and can() is for validation conditions, while try() returns a fallback value.