Terraform Expressions and Functions
The expression language behind Terraform configurations. Types, for expressions, conditionals, the function categories that matter, and how to test them safely.
HCL is a configuration language with a real expression system underneath it. Most of what separates a readable Terraform configuration from a repetitive one is knowing which expressions exist.
There is no user-defined function support. You use the built-in library, composed with for expressions and locals.
Types
| Type | Example |
|---|---|
string | "eu-west-1" |
number | 3 |
bool | true |
list / tuple | ["a", "b"] (ordered, duplicates allowed) |
set | toset(["a", "b"]) (unordered, unique) |
map / object | { env = "prod" } |
The list-versus-set distinction matters for for_each, which needs a map or a set because it requires stable keys. Converting with toset() is a routine step.
Terraform converts types automatically where it can, a number used in a string context becomes a string, which is convenient and occasionally surprising when a value you expected to be numeric arrives quoted.
Referencing things
var.environment # input variable
local.name_prefix # local value
aws_instance.web.id # resource attribute
aws_instance.web[0].id # count instance
aws_subnet.this["public-a"].id # for_each instance
module.vpc.vpc_id # module output
data.aws_ami.al2023.id # data source
each.key / each.value # inside for_each
count.index # inside count
Interpolation embeds an expression in a string: "${var.project}-${var.environment}". Do not wrap a value that is already a string, name = var.name, not name = "${var.name}".
Conditionals
instance_type = var.environment == "prod" ? "m6i.large" : "t3.micro"
count = var.enabled ? 1 : 0
Both branches must return the same type. Chaining more than two levels becomes unreadable quickly. A lookup() against a map of per-environment values is usually clearer:
locals {
sizes = { dev = "t3.micro", staging = "t3.medium", prod = "m6i.large" }
instance_type = lookup(local.sizes, var.environment, "t3.micro")
}
for expressions
The most powerful construct in the language. It transforms one collection into another.
# list -> list
[for name in var.names : upper(name)]
# list -> list, filtered
[for s in var.subnets : s.id if s.public]
# list of objects -> map, the for_each pattern
{ for s in var.subnets : s.name => s }
# map -> map
{ for k, v in var.tags : lower(k) => v }
Square brackets produce a list or tuple; braces produce a map and require the key => value form. The optional if clause filters. There is no separate filter function, and this is the idiomatic way to do it.
Filtering and transforming happen in one pass, which is why a for expression usually replaces what would be several steps elsewhere.
The function categories worth knowing
Strings: join, split, format, lower, upper, replace, trimspace, substr, startswith, endswith
join(",", var.subnet_ids)
format("%s-%03d", var.prefix, count.index) # zero-padded, sorts correctly
lower(replace(var.name, "_", "-")) # normalise for S3 naming rules
Collections: merge, concat, flatten, lookup, keys, values, contains, length, distinct, toset
merge(local.common_tags, { Name = "web" }) # the standard tagging pattern
flatten([for az in var.azs : [for t in var.tiers : "${az}-${t}"]])
Numeric: min, max, ceil, floor, abs
Encoding: jsonencode, jsondecode, base64encode, yamlencode
jsonencode is how IAM policies should be written, rather than as heredoc strings. It validates structure and handles escaping:
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject"]
Resource = "${aws_s3_bucket.this.arn}/*"
}]
})
Network: cidrsubnet, cidrhost, cidrnetmask
cidrsubnet("10.0.0.0/16", 8, 1) # "10.0.1.0/24"
Deriving subnet ranges beats hand-calculating them, and it keeps the arithmetic correct when the VPC CIDR changes.
Filesystem and templates: file, templatefile
user_data = templatefile("${path.module}/init.sh.tftpl", {
app_port = var.app_port
env = var.environment
})
Keep scripts in real files rather than heredocs. They get syntax highlighting, linting, and a sensible diff.
try, can and one
Three functions for handling values that might not exist:
try(var.config.timeout, 30) # first expression that succeeds
can(regex("^prod-", var.name)) # returns bool instead of erroring
one(aws_instance.web[*].id) # single value or null, for count = 0/1
lookup(map, key, default) handles a straightforward map access with a fallback. try() is broader, catching type errors and missing attributes on nested objects. can() is what you want inside a validation block, since it returns a boolean rather than failing.
Test expressions before you apply
terraform console
> cidrsubnet("10.0.0.0/16", 8, 3)
"10.0.3.0/24"
> { for s in var.subnets : s.name => s.cidr }
{
"public-a" = "10.0.1.0/24"
}
terraform console has access to your variables, locals, data sources and state. It is read-only and safe to run against production, and it is by far the fastest way to work out why an expression is not producing what you expect. Much better than adding an output and running a plan.
Explore further in this section
| Sub-topic | What you will learn |
|---|---|
| String Functions | join, split, format, regex and normalisation |
| Collection Functions | merge, flatten, lookup and for expressions |
| Dynamic Blocks | Generating repeated nested blocks |
Quick reference
| Task | Expression |
|---|---|
| Conditional | cond ? a : b |
| Transform a list | [for x in list : f(x)] |
| Filter a list | [for x in list : x if cond] |
| List to map | { for x in list : x.key => x } |
| Merge maps | merge(a, b) |
| Flatten nesting | flatten([...]) |
| Map access with default | lookup(map, key, default) |
| Safe fallback | try(expr, default) |
| Boolean test | can(expr) |
| Optional single value | one(list) |
| Derive a subnet | cidrsubnet(cidr, newbits, netnum) |
| Render a template | templatefile(path, vars) |
| Test interactively | terraform console |
Final Thoughts
Two things carry most of the weight here: the for expression, which turns lists into the maps for_each needs and filters in the same pass, and terraform console, which removes guesswork entirely. Learn those two and the function library becomes something you look up rather than memorise.
Frequently Asked Questions
How do I test a Terraform expression without applying?
Use terraform console, an interactive prompt with access to your variables, locals, data sources and current state. Type an expression and it prints the result immediately. It is read-only and safe to run against production, and it answers "why is this not producing what I expect" far faster than adding a temporary output and running a plan. It is the single most underused Terraform command.
How do I write a conditional in Terraform?
With the ternary operator: var.env == "prod" ? "m6i.large" : "t3.micro". Both branches must return the same type. For conditional resource creation, use count = var.enabled ? 1 : 0. When you find yourself chaining more than two conditions, replace it with a map of values and lookup(map, key, default), which is considerably easier to read and to extend.
What is a for expression and when do I use it?
It transforms one collection into another, with optional filtering: [for s in var.subnets : s.id if s.public]. Square brackets produce a list, braces produce a map using key => value syntax. The most important use is converting a list into the map that for_each requires, since for_each needs stable keys. There is no separate filter function in Terraform. The if clause of a for expression is how filtering is done.
How do I merge maps or combine tags in Terraform?
merge(local.common_tags, { Name = "web-server" }), where later arguments win on key conflicts. This is the standard tagging pattern: define organisation-wide tags once in a locals block and merge per-resource additions at each use site. Many providers also support default_tags at the provider level, which applies tags automatically to every resource. Combining both gives you provider-wide defaults plus per-resource specifics with no repetition.
What is the difference between try, can and lookup?
lookup(map, key, default) retrieves a value from a map with a fallback when the key is missing. try(expr, fallback) evaluates expressions in order and returns the first that succeeds, handling a wider range of failures including type errors and missing attributes on nested objects. can(expr) returns a boolean instead of erroring, which is what makes it suitable inside a variable validation block where you need a condition rather than a value.
Can I write my own functions in Terraform?
Not in Terraform itself. The function library is fixed and there is no user-defined function support. The idiomatic substitute is a locals block, which lets you name a complex expression once and reuse it everywhere, giving most of the readability benefit. For genuinely complex logic, either reshape the input data so the expression becomes simple, or move the computation outside Terraform and pass the result in as a variable.