Going to AWS re:Invent 2024?

➡️ Book a meeting with Spacelift

Terraform

Terraform Functions, Expressions, Loops (Examples)

terraform functions

The native language of Terraform, called HCL, can be an extremely convenient and efficient tool, if you know it well. Although it is not a programming language (even though operators coding in HCL are often jokingly called “HCL Developers”), it does implement some structures and logic that bear resemblance to traditional programming. 

In this article, we will discuss the HCL’s core – built-in functions, expressions and loops. What are those, and what are they for? Let’s dive in.

Terraform functions

What is a function in Terraform?

Terraform functions are built-in, reusable code blocks that perform specific tasks within Terraform configurations. They make your code more dynamic and ensure your configuration is DRY. Functions allow you to perform various operations, such as converting expressions to different data types, calculating lengths, and building complex variables.

In Terraform, functions are split into many categories:

Function Type Description
String String related operations (format, join, split)
Numeric Numeric related operations (min, max, pow)
Collection Functions that manipulate lists, tuples, sets and maps (length, lookup, merge)
Date and Time Manipulate date and time (formatdate, timestamp)
Crypto and Hash    Crypto and Hash functions (base64sha512, bcrypt)
Filesystem File system operations (file, filexists, abspath)
Ip Network Network Cidr functions (cidrsubnet, cidrhost)
Encoding Encoding and decoding functions (base64decode, base64encode, jsonencode)
Type Conversion Functions that convert data types (tobool, tomap, tolist)

1. ToType Functions

2. format(string_format, unformatted_string)

locals {
 string1       = "str1"
 string2       = "str2"
 int1          = 3
 apply_format  = format("This is %s", local.string1)
 apply_format2 = format("%s_%s_%d", local.string1, local.string2, local.int1)
}

output "apply_format" {
 value = local.apply_format
}
output "apply_format2" {
 value = local.apply_format2
}

This will result in:

apply_format  = "This is str1"
apply_format2 = "str1_str2_3"

3. formatlist(string_format, unformatted_list)

locals {
 format_list = formatlist("Hello, %s!", ["A", "B", "C"])
}

output "format_list" {
 value = local.format_list
}

The output will be:

format_list = tolist(["Hello, A!", "Hello, B!", "Hello, C!"])

4. length(list / string / map)

locals {
 list_length   = length([10, 20, 30])
 string_length = length("abcdefghij")
}

output "lengths" {
 value = format("List length is %d. String length is %d", local.list_length, local.string_length)
}

This will result in:

lengths = "List length is 3. String length is 10"

5. join(separator, list)

locals {
 join_string = join(",", ["a", "b", "c"])
}

output "join_string" {
 value = local.join_string
}

6. try(value, fallback)

locals {
 map_var = {
   test = "this"
 }
 try1 = try(local.map_var.test2, "fallback")
}

output "try1" {
 value = local.try1
}

7. can(expression)

variable "a" {
 type = any
 validation {
   condition     = can(tonumber(var.a))
   error_message = format("This is not a number: %v", var.a)
 }
 default = "string"
}

8. flatten(list)

locals {
 unflatten_list = [[1, 2, 3], [4, 5], [6]]
 flatten_list   = flatten(local.unflatten_list)
}

output "flatten_list" {
 value = local.flatten_list
}

9. keys(map) & values(map)

locals {
 key_value_map = {
   "key1" : "value1",
   "key2" : "value2"
 }
 key_list   = keys(local.key_value_map)
 value_list = values(local.key_value_map)
}

output "key_list" {
 value = local.key_list
}

output "value_list" {
 value = local.value_list
}

The output of this code will be:

key_list = ["key1", "key2"]
value_list = ["value1", "value2"]

10. slice(list, startindex, endindex)

locals {
 slice_list = slice([1, 2, 3, 4], 2, 4)
}


output "slice_list" {
 value = local.slice_list
}

11. range

  • one argument(limit)
  • two arguments(initial_value, limit)
  • three arguments(initial_value, limit, step)
locals {
 range_one_arg    = range(3)
 range_two_args   = range(1, 3)
 range_three_args = range(1, 13, 3)
}

output "ranges" {
 value = format("Range one arg: %v. Range two args: %v. Range three args: %v", local.range_one_arg, local.range_two_args, local.range_three_args)
}

The output for this code would be:

range = "Range one arg: [0, 1, 2]. Range two args: [1, 2]. Range three args: [1, 4, 7, 10]"

12. lookup(map, key, fallback_value)

locals {
 a_map = {
   "key1" : "value1",
   "key2" : "value2"
 }
 lookup_in_a_map = lookup(local.a_map, "key1", "test")
}


output "lookup_in_a_map" {
 value = local.lookup_in_a_map
}

Learn more about the Terraform lookup function.

13. concat(lists)

locals {
 concat_list = concat([1, 2, 3], [4, 5, 6])
}


output "concat_list" {
 value = local.concat_list
}

14. merge(maps)

locals {
 b_map = {
   "key1" : "value1",
   "key2" : "value2"
 }
 c_map = {
   "key3" : "value3",
   "key4" : "value4"
 }
 final_map = merge(local.b_map, local.c_map)
}


output "final_map" {
 value = local.final_map
}

The above code will return:

final_map = {
  "key1" = "value1"
  "key2" = "value2"
  "key3" = "value3"
  "key4" = "value4"
}

15. zipmap(key_list, value_list)

locals {
 key_zip    = ["a", "b", "c"]
 values_zip = [1, 2, 3]
 zip_map    = zipmap(local.key_zip, local.values_zip)
}

output "zip_map" {
 value = local.zip_map
}

This code will return:

zip_map = {
  "a" = 1
  "b" = 2
  "c" = 3
}

16. expanding function argument …

locals {
 list_of_maps = [
   {
     "a" : "a"
     "d" : "d"
   },
   {
     "b" : "b"
     "e" : "e"
   },
   {
     "c" : "c"
     "f" : "f"
   },
 ]
 expanding_map = merge(local.list_of_maps...)
}

output "expanding_map" {
 value = local.expanding_map
}

This will result in:

expanding_map = {
  "a" = "a"
  "b" = "b"
  "c" = "c"
  "d" = "d"
  "e" = "e"
  "f" = "f"
}

17. file(path_to_file)

locals {
  a_file = file("./a_file.txt")
}

output "a_file" {
  value = local.a_file
}

18. templatefile(path, vars)

locals {
 a_template_file = templatefile("./file.yaml", { "change_me" : "awesome_value" })
}


output "a_template_file" {
 value = local.a_template_file
}

19. jsondecode(string)

locals {
 a_jsondecode = jsondecode("{\"hello\": \"world\"}")
}


output "a_jsondecode" {
 value = local.a_jsondecode
}

This will return:

jsondecode = {
 "hello" = "world"
}

20. jsonencode(string)

locals {
 a_jsonencode = jsonencode({ "hello" = "world" })
}


output "a_jsonencode" {
 value = local.a_jsonencode
}

This results in:

a_jsonencode = "{\"hello\":\"world\"}"

21. yamldecode(string)

locals {
 a_yamldecode = yamldecode("hello: world")
}


output "a_yamldecode" {
 value = local.a_yamldecode
}

This returns:

a_yamldecode = {
 "hello" = "world"
}

22. yamlencode(value)

locals {
 a_yamlencode = yamlencode({ "a" : "b", "c" : "d" })
}


output "a_yamlencode" {
 value = local.a_yamlencode
}

This will return:

a_yamlencode = <<EOT
"a": "b"
"c": "d"

EOT

Can we create custom functions in Terraform?

In Terraform, you cannot define custom functions in the same way you might in traditional programming languages. What you can do, however, is use provider-defined functions. These functions are implemented by providers and can be used from hcl. This feature is relatively new, and there are not many examples available.

How to test Terraform functions?

The most straightforward ways to test a function are:

  • using terraform console
terraform console
> max(1, 3, 5)
5
  • using a new Terraform file outside of your workflow and defining locals and outputs
locals {
 a = max(1, 3, 5)
}

output "a" {
 value = local.a
}
terraform apply

Changes to Outputs:
  + a = 5

You can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

a = 5

Terraform expressions

What is an expression in Terraform?

Expressions are the core of HCL itself – the logic muscle of the entire language. Terraform expressions allow you to get a value from somewhere, calculate or evaluate it. You can use them to refer to the value of something, or extend the logic of a component – for example, make one copy of the resource for each value contained within a variable, using it as an argument.

They are used pretty much everywhere – the most simple type of expression would be a literal value – so, there is a great chance that you have already used them before.

As it is an extremely extensive topic, I couldn’t possibly fit everything into this single article. Instead, I can talk about what in my humble opinion are the most notable/interesting expressions: Operators, Conditional expressions, Splat Expressions, Constraints, and Loops.

1. Operators

Dedicated to logical comparison and arithmetic operations, operators are mostly used for doing math and basic Bool’s algebra. If you need to know if number A equals number B, add them together, or determine if both boolean A and boolean B are “true”, Terraform offers the following operators:

Types of Terraform Operators

  • Arithmetic operators – the basic ones for typical math operations (+, -, *, /) and two special ones: “X % Y” would return the remainder of dividing X by Y, and “-X”, which would return X multiplied by -1. Those can only be used with numeric values.
  • Equality operators – “X == Y” is true, if X and Y have both the same value and type, “X != Y” would return false in this case. This one will work with any type of value.
  • Comparison operators – “<, >, <=, >=” – exclusive to numbers, returns true or false depending on the condition. 
  • Logical operators – the Bool’s algebra part of the pack, work only with the boolean values of true and false.
    – “X || Y” returns true if either X or Y is true, false if any of them is false.
    – “X && Y” returns true only, if both X and Y are true, false if any of them is false.
                – “!X” is true, if X is false, false if X is true.

2. Conditionals

Sometimes, you might run into a scenario where you’d want the argument value to be different, depending on another value. The conditional syntax is as such:

condition ? true_val : false_val

The condition part is constructed using previously described operators. In this example, the bucket_name value is based on the “test” variable—if it’s set to true, the bucket will be named “dev” and if it’s false, the bucket will be named “prod”:

bucket_name = var.test == true ? "dev" : "prod"

3. Splat expressions

Splat expressions are used to extract certain values from complicated collections – like grabbing a list of attributes from a list of objects containing those attributes. Usually, you would need an “for” expression to do this, but humans are lazy creatures who like to make complicated things simpler.

For example, if you had a list of objects such as these:

test_variable = [
 {
   name  = "Arthur",
   test  = "true"
 },
 {
   name  = "Martha"
   test  = "true"
 }
]

Instead of using the entire “for” expression:

[for o in var.test_variable : o.name]

you could go for the splat expression form:

var.test_variable[*].name

And in both cases, get the same result:

["Arthur", "Martha"]

Do note, that this behavior applies only if splat was used on a list, set, or tuple. Anything else (Except null) will be transformed into a tuple, with a single element inside, null will simply stay as is. This may be good or bad, depending on your use case. I would suggest you read the official documentation on the topic.

4. Constraints

In simple terms, constraints regulate what can be what and where something can or cannot be used. There are two main types of constraints—for types and versions.

  • Type constraints regulate the values of variables and outputs. For example, a string is represented by anything enclosed in quotes, bool value is either a literal true or false, a list is always opened with square brackets [ ], a map is defined with curly brackets { }.
  • Version constraints usually apply to modules and regulate which versions should or should not be used. See this article to learn more about it: What are Terraform Modules and How to Use Them (Examples)

Terraform loops

Ah yes, the elephant in the room. Mentioned a few times, but still unexplained—until now.

What is a loop in Terraform?

Terraform loops are used to handle collections, and to produce multiple instances of a resource or module without repeating the code. There are three loops provided by Terraform to date:

  • Count
  • For_each
  • For

1. Count

Count is the most primitive—it allows you to specify a whole number, and produces as many instances of something as this number tells it to. For example, the following would order Terraform to create ten S3 buckets:

resource "aws_s3_bucket" "test" {
 count = 10
[...]
}

When count is in use, each instance of a resource or module gets a separate index, representing its place in the order of creation. To get a value from a single resource created in this way, you must refer to it by its index value, e.g. if you wished to see the ID of the fifth created S3 bucket, you would need to call it as such:

aws_s3_bucket.test[5].id

Although this is fine for identical, or nearly identical objects, as previously mentioned, count is pretty primitive. When you need to use more distinct, complex values – count yields to for_each.

2. For_each

As mentioned earlier, sometimes you might want to create resources with distinct values associated with each one – such as names or parameters (memory or disk size for example). For_each will let you do just that. Merely provide a variable—map, or a set of strings, and the resources can access values contained within, via each.key and each.value:

test_map = {
 test1 = "test2",
 test2 = "test4"
}
 
resource "test_resource" "thing" {
 for_each = var.test_map
 
 test_attribute_1 = each.key
 test_attribute_2 = each.value
}

As you can see, for_each is quite powerful, but you haven’t seen the best yet. By constructing a map of objects, you can leverage a resource or module to create multiple instances of itself, each with multiple declared variable values:

my_instances = {
 instance_1 = {
   ami   = "ami-00124569584abc",
   type  = "t2.micro"
 },
 instance_2 = {
   ami   = "ami-987654321xyzab",
   type  = "t2.large"
 },
}
 
resource "aws_instance" "test" {
 for_each = var.my_instances
 
 ami           = each.value["ami"]
 instance_type = each.value["type"]
}

Using this approach, you don’t have to touch anything except the .tfvars file, to provide new instances of resources you have already declared in your configuration. Absolutely brilliant.

If you want to know more about Terraform count and for_each meta-arguments, see: Terraform Count vs. For_Each Meta-Argument: When to Use Them

3. For

For is made for picking out, iterating over, and operating on things from complex collections. Imagine that you have a list of words (strings), but unfortunately, all of them contain newline characters at the end which you don’t want. Like this:

word_list = [
"Critical\n",
"failure\n", 
"teapot\n", 
"broken\n"
]

To fix this problem, you could do

[for word in var.word_list : chomp(word)]

which would result in:

["Critical", "failure", "teapot", "broken"]

As you can see, a list comes in, a list goes out—but, this is not a must. The type of input, and the brackets which wrap the for expression, determine the type of output it produces. If you had wrapped it with curly brackets, and provided a map as an input, the output would have been a map. Quite interesting.

But there’s one thing that’s even more interesting—the for expression can also be used to filter the input as you please. By adding an if clause, you can conditionally operate or not operate on certain values, depending on your defined terms. Take this example, making every word start with a capital letter… except for the word “teapot”:

[for word in var.word_list : upper(word) if word != "teapot"]

For can do all that, and a lot more. Check out this page of the official Terraform documentation if you want to know more – it’s really powerful and can help you with many tasks that lie ahead.

Key points

Regarding the joke I have mentioned earlier—as you can see from this piece of Terraform syntax pie, HCL is very, very extensive. It couldn’t compete with most programming languages, but when it comes to infrastructure, Terraform usually has just what you need. HCL is also simple to learn, albeit very intricate under the hood—to the point from which to consider it a tiny programming language indeed wouldn’t be much of a stretch.

I hope this brief glimpse into the world of Terraform functions, expressions, and loops will help you to bring the awesome powers they provide into your configurations. Have fun, and have a productive day.

Note: New versions of Terraform are placed under the BUSL license, but everything created before version 1.5.x stays open-source. OpenTofu is an open-source version of Terraform that expands on Terraform’s existing concepts and offerings. It is a viable alternative to HashiCorp’s Terraform, being forked from Terraform version 1.5.6.

If you’re interested in finding out how to use Spacelift to manage and automate your Terraform deployments check out our Terraform documentation and get started on your journey by creating a free trial account.

Discover better way to manage Terraform

Spacelift helps manage Terraform state, build more complex workflows, supports policy as code, programmatic configuration, context sharing, drift detection, resource visualization and many more.

Start free trial
Terraform Functions Cheatsheet

Grab our ultimate cheat sheet PDF for all the Terraform functions you need on hand.

Share your data and download the cheatsheet