Terraform Expressions and Functions

By Vishvesh Patel · DevOps EngineerUpdated September 13, 2026

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

TypeExample
string"eu-west-1"
number3
booltrue
list / tuple["a", "b"] (ordered, duplicates allowed)
settoset(["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-topicWhat you will learn
String Functionsjoin, split, format, regex and normalisation
Collection Functionsmerge, flatten, lookup and for expressions
Dynamic BlocksGenerating repeated nested blocks

Quick reference

TaskExpression
Conditionalcond ? 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 mapsmerge(a, b)
Flatten nestingflatten([...])
Map access with defaultlookup(map, key, default)
Safe fallbacktry(expr, default)
Boolean testcan(expr)
Optional single valueone(list)
Derive a subnetcidrsubnet(cidr, newbits, netnum)
Render a templatetemplatefile(path, vars)
Test interactivelyterraform 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.

Continue Learning

Explore Related Topics

Try the Tool

Practise this in the browser

Terraform Playground

Run init, plan, apply and destroy against simulated infrastructure.

When it breaks

Related Resources