Built-in Functions
Terraform ships a library of built-in functions for strings, collections, encoding, filesystem, date, and crypto — merge, lookup, coalesce, jsonencode, templatefile, cidrsubnet, and dozens more. There are no user-defined functions in core HCL — provider-defined functions arrived in 1.8 — so this fixed library plus expressions is your whole toolbox for transforming data.
That constraint shapes how you write Terraform. You cannot define a helper to factor out a repeated transformation, so the skill is recognizing which built-in already does what you want. Most "I wish I could write a function for this" moments dissolve into a merge, a for expression, or a templatefile once you know the library.
Collection Functions
merge combines maps with later keys winning, concat joins lists, flatten collapses nested lists, and lookup reads a map key with a fallback. keys, values, and contains inspect a collection. These are the workhorses for reshaping the data that feeds for_each and dynamic blocks.
locals { all_tags = merge(var.default_tags, var.extra_tags) # lookup with a default avoids erroring on a missing key size = lookup(var.sizes, var.environment, "t3.micro") }
String Functions
format builds a string from a template and arguments, join and split convert between a list and a delimited string, replace substitutes text, and lower/upper/trimspace normalize. These keep name construction readable instead of a pile of nested interpolations.
locals { name = format("%s-%s-%03d", var.project, var.environment, var.index) az_list = join(",", var.availability_zones) slug = lower(replace(var.display_name, " ", "-")) }
Encoding Functions
jsonencode and yamlencode turn an HCL value into a correctly-escaped JSON or YAML string, and their decode counterparts parse one back. This is the right way to build an IAM policy or a config document — you write the structure as native HCL and let the function produce valid serialized text, instead of concatenating braces and quotes by hand.
resource "aws_iam_role_policy" "app" { role = aws_iam_role.app.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = ["s3:GetObject"] Resource = "${aws_s3_bucket.data.arn}/*" }] }) }
Filesystem and Template Functions
file reads a file's contents into a string, and templatefile renders a separate template file with a map of variables — the clean way to produce user-data or a config file. Keeping the template in its own file, rather than inline as a heredoc, makes it readable and lets editors highlight it correctly.
resource "aws_instance" "web" { ami = "ami-0abc123" instance_type = "t3.micro" user_data = templatefile("${path.module}/init.sh.tftpl", { app_env = var.environment port = 8080 }) }
Network and Numeric Functions
cidrsubnet carves a subnet out of a larger CIDR block by adding bits and selecting an index, and cidrhost returns a specific host address within a range. Computing these by hand is how overlapping or wrong ranges slip in; the functions make subnet math deterministic. min, max, ceil, and floor round out the numeric set.
locals { # /16 base, add 8 bits to get /24s, by index subnet_a = cidrsubnet("10.0.0.0/16", 8, 0) # 10.0.0.0/24 subnet_b = cidrsubnet("10.0.0.0/16", 8, 1) # 10.0.1.0/24 }
- Reaching for a function that doesn't exist — there are no arbitrary user-defined functions in core — instead of restructuring with the ones provided.
- Calling
lookupwithout a default on a key that may be absent, getting an error where a fallback was intended. - Hand-computing subnet CIDRs instead of
cidrsubnet, producing overlapping or wrong ranges that peering later rejects. - Embedding a rendered template inline with string concatenation rather than
templatefile, making it unreadable and error-prone. - Concatenating a JSON policy by hand instead of
jsonencode, so a special character in an ARN produces invalid JSON.
- Use
templatefilefor user-data and rendered config, keeping the template in its own.tftplfile. - Compute subnet ranges with
cidrsubnetandcidrhostrather than by hand, so the math is deterministic. - Provide a default to
lookup(or usetry) so a missing key degrades gracefully instead of erroring. - Build structured documents with
jsonencodeandyamlencode, never string concatenation. - Reach for
mergeto combine tag maps andcoalesceto pick the first non-null value, instead of nested conditionals.
Knowledge Check
You want a helper that factors out a transformation you repeat in three places. What does core HCL let you do?
- Nothing custom — core has no user-defined functions, so you restructure with the built-ins or a local
- Define a top-level
functionblock in the file and call it by name like any built-in helper would be invoked - Import a callable function from another module's exported outputs and invoke it by name from the parent
- Write an inline lambda with the
=>operator and reuse the closure
Why pass a default to lookup(var.sizes, var.environment, "t3.micro")?
- If the key is absent, the default is returned instead of the expression erroring
- The default encrypts the looked-up value before it is returned to the calling expression
lookuprequires exactly three arguments and fails with only two- The default makes the key match case-insensitively against the map
What problem does templatefile solve over an inline heredoc?
- It keeps the template in its own file, rendered with named variables, so it stays readable and highlightable
- It is the only construct in the language that can interpolate a variable into a string literal at render time
- It encrypts the rendered user-data output before it ever reaches the launching instance
- It executes the rendered template as a shell script at plan time
When does cidrsubnet prevent a real bug?
- It computes non-overlapping subnet ranges deterministically, avoiding the overlaps hand-math produces that later break peering
- It validates that each derived CIDR block is publicly routable and non-overlapping before any VPC peering connection is established
- It automatically assigns each computed subnet to a distinct availability zone in the region
- It encrypts traffic flowing between the subnets it computes from the base block
You got correct