Chapter 5: The Language — Variables, Outputs, Expressions
Topic 33

Built-in Functions

LanguageFunctions

Terraform ships a library of built-in functions — string, collection, numeric, encoding, filesystem, and date families — and you call them inline anywhere an expression is allowed. There is no syntax for defining your own function in Terraform 1.x: you compose the built-ins, and you push genuinely reusable logic into a module.

Two functions earn their keep on GCP above all the rest. jsonencode builds IAM policies and other JSON arguments with correct escaping, and templatefile renders external files so a startup script or config stays a reviewable file instead of a wall of heredoc inside the .tf. The rest of the library is everyday shaping of labels, names, and lists.

String and Collection Families

The string family covers the everyday reshaping of names and labels: lower, replace, format, join and split, trimspace. The collection family does the same for lists and maps: merge, concat, keys, values, lookup, contains, flatten, distinct. These are the functions you reach for constantly to shape an input into the exact form a resource argument wants.

Encoding Functions

The encoding family — jsonencode and jsondecode, base64encode, yamlencode, urlencode — converts between HCL values and serialized formats. jsonencode is the one you reach for repeatedly on GCP, because IAM policies and many resource arguments want correctly-escaped JSON, and building that JSON by hand is where quoting bugs live.

jsonencode for GCP Policy Documents

Building a google_project_iam_policy document or a custom-role permission set as an HCL object and wrapping it in jsonencode produces valid JSON with correct quoting — instead of a fragile hand-built string that breaks the moment a value contains a quote or a trailing comma lands wrong. The reverse, jsondecode(file(...)), reads an external policy file back into HCL so you can manage it as data.

A policy built as an object, encoded to JSON
locals {
  processor_policy = jsonencode({
    bindings = [{
      role    = "roles/pubsub.publisher"
      members = ["serviceAccount:${google_service_account.processor.email}"]
    }]
  })   # correct escaping, no hand-built quotes
}

The object is readable HCL; jsonencode turns it into valid JSON. The service-account email interpolates cleanly, and there is no trailing-comma or quote-escaping bug to chase.

templatefile

templatefile renders an external template file with a set of variables — the clean way to produce a Cloud Run startup config, a cloud-init, or a parameterized SQL or JSON file without cramming multi-line content into the .tf. The template lives as its own reviewable file, and the resource passes it the values it needs.

Rendering an external template
resource "google_compute_instance" "runner" {
  # ... 
  metadata_startup_script = templatefile("${path.module}/startup.sh.tftpl", {
    bucket = google_storage_bucket.raw.name
  })
}

The startup script stays a file a reviewer can read on its own, and Terraform substitutes bucket at plan time. Inlining the same script as a heredoc would bury it in the resource block and lose the separate review.

try, can, and coalesce

Three functions handle optional and missing data gracefully. can(...) turns an error into a boolean — the reason a regex check works inside a validation block. try(a, b, c) returns the first expression that does not error, useful when an attribute may or may not exist. coalesce and coalescelist return the first non-null argument, the tool for falling back from an optional input to a default. Reaching for these beats letting a missing-key lookup abort the whole plan.

No User-Defined Functions

Terraform 1.x has no way to define your own function in HCL — there is no function "..." {} block, and OpenTofu has none either. When you find yourself repeating a gnarly built-in composition, the answer is to factor the logic into a module or repeat the composition. The closest thing either offers is provider-defined functions — functions a provider registers that you call as provider::name::func — but those come from a provider plugin, not from your own HCL. OpenTofu pushes this further with experimental dynamic providers (Lua, Go) that let a provider compute functions from your config, a divergence from Terraform; still, neither tool lets you name a reusable expression inline.

No user-defined functions: Terraform and OpenTofu
Terraform 1.x
Built-in library only, no HCL function blocks. Reusable logic lives in modules; the model this course teaches.
OpenTofu
Also no HCL user-defined functions. Provider-defined functions (provider::name::func) exist in both tools; OpenTofu's only extra is experimental dynamic-provider functions in Lua or Go.
Terraform 1.x functions vs OpenTofu functions

Terraform 1.x — only the built-in library, with no user-defined functions. Reusable logic lives in modules or in repeated built-in compositions. This is the model this course teaches and the default for most teams.

OpenTofu — also has no HCL user-defined functions; you cannot define a function in your own configuration. Provider-defined functions (called as provider::name::func) exist in both tools — Terraform added them in 1.8, OpenTofu in 1.7 — so they are not a divergence. OpenTofu's genuine extra is experimental dynamic providers (Lua, Go) that compute functions from your config; either way, the function comes from a provider plugin, not from named expressions in your .tf.

Common Mistakes
  • Hand-building an IAM policy or JSON argument as an interpolated string and shipping invalid JSON the moment a value contains a quote or the trailing comma is wrong — jsonencode an HCL object instead and let it handle escaping.
  • Embedding a long startup script or config inline in the .tf when templatefile("startup.sh.tftpl", {...}) would keep it a reviewable separate file.
  • Expecting to define a reusable function in HCL and discovering there is no such block in Terraform 1.x — and none in OpenTofu either; the logic has to go into a module or be repeated.
  • Using lookup(map, key) without a default and getting an error on a missing key when lookup(map, key, fallback) or try was wanted.
  • Wrapping a value in jsondecode(jsonencode(x)) to "deep-copy" or normalize it when the original value would have served, adding a round-trip that obscures intent.
Best Practices
  • Build every JSON document — IAM policies, structured resource arguments — with jsonencode over an HCL object, never as a hand-interpolated string.
  • Use templatefile for any multi-line or parameterized external content so the template stays a reviewable file separate from the .tf.
  • Reach for try, coalesce, and can to handle optional and missing values gracefully instead of letting a lookup error abort the plan.
  • Do not fight the lack of user-defined functions — neither Terraform 1.x nor OpenTofu lets you define one in HCL, so factor reusable logic into a module or, where a provider offers them, use provider-defined functions.
  • Pass lookup a fallback default for any key that may be absent, so a missing key degrades to the default instead of erroring mid-plan.
Comparable tools Pulumi the host language's full standard library, no built-in-only limit Jsonnet standard library and user functions Helm · Sprig template functions jq for the JSON-shaping role

Knowledge Check

Why build a GCP IAM policy with jsonencode over an HCL object instead of a hand-interpolated string?

  • jsonencode handles quoting and escaping, so a value containing a quote still produces valid JSON; a hand-built string ships malformed JSON
  • A hand-built string cannot interpolate a service-account email into a members array the way an HCL object can
  • jsonencode is the only function the google_project_iam_policy resource accepts in its policy argument
  • It encrypts the rendered policy document with the project KMS key before sending it to the IAM API, so the binding never travels in plaintext

What does templatefile buy over an inline heredoc for a startup script?

  • The template stays a separate, reviewable file rendered with variables, instead of a multi-line block buried in the resource
  • It runs the rendered script during the plan phase to validate that every command exits cleanly before any instance is created
  • It is the only way to pass var values into a Compute Engine startup script
  • An inline heredoc is capped at 256 lines while templatefile has no such limit

Which function turns an erroring expression into a boolean, the reason it is used inside a validation block?

  • can(...) — it returns true or false instead of propagating the error
  • coalesce(...) — it returns the first non-null argument from the list you pass it
  • try(...) — it returns the first of its expressions that evaluates without erroring
  • lookup(...) — it returns the supplied default when a map key is missing

Where do Terraform 1.x and OpenTofu diverge on functions?

  • Neither lets you define a function in HCL; both support provider-defined functions, and OpenTofu's only extra is experimental dynamic-provider functions computed in another language
  • OpenTofu added user-defined function blocks authored in HCL that you can declare in a module and call by name anywhere, while Terraform 1.x is still limited to the fixed set of built-ins
  • OpenTofu dropped jsonencode in favour of yamlencode for serializing structured values
  • They ship entirely different built-in function names across the standard library

You got correct