Expressions & Functions

Master Terraform's built-in functions and expression syntax

9 min read

Expressions & Functions

In the previous tutorial, we learned about data sources — reading existing infrastructure. Now let's add some brains to our configs.

Terraform isn't a programming language, but it has expressions. Conditionals, loops, string manipulation, math — enough to build flexible configs without writing code. Think of it as a Swiss Army knife: not a full toolbox, but surprisingly capable.

Basic Expressions

References

# Variable
var.instance_type

# Local
local.common_tags

# Resource attribute
aws_instance.web.id

# Data source
data.aws_ami.ubuntu.id

# Module output
module.vpc.subnet_ids

Arithmetic

locals {
  total_storage = var.base_storage + var.extra_storage
  doubled       = var.count * 2
  percentage    = var.value / 100
}

String Interpolation

locals {
  name    = "web-${var.environment}"
  message = "Server ${aws_instance.web.id} is in ${data.aws_region.current.name}"
}

Conditionals

"Can I do if/else in Terraform?"

Sort of! You get the ternary operator:

Ternary Operator

condition ? true_value : false_value

Examples:

locals {
  instance_type = var.environment == "prod" ? "t2.large" : "t2.micro"
  
  enable_monitoring = var.environment == "prod" ? true : false
  
  # Multiple conditions
  size = (
    var.environment == "prod" ? "large" :
    var.environment == "staging" ? "medium" :
    "small"
  )
}

Conditional Resources

"What if I only want a resource in production?"

The classic count trick:

# Create only if condition is true
resource "aws_eip" "web" {
  count    = var.create_eip ? 1 : 0
  instance = aws_instance.web.id
}

# Reference conditionally created resource
output "eip" {
  value = var.create_eip ? aws_eip.web[0].public_ip : null
}

For Expressions

"Can I loop through a list and transform it?"

Yep! for expressions are like list comprehensions in Python.

Transform Lists

# Input
variable "names" {
  default = ["alice", "bob", "charlie"]
}

# Transform each element
locals {
  upper_names = [for name in var.names : upper(name)]
  # ["ALICE", "BOB", "CHARLIE"]
  
  prefixed = [for name in var.names : "user-${name}"]
  # ["user-alice", "user-bob", "user-charlie"]
}

Filter Lists

variable "numbers" {
  default = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
}

locals {
  evens = [for n in var.numbers : n if n % 2 == 0]
  # [2, 4, 6, 8, 10]
  
  large = [for n in var.numbers : n if n > 5]
  # [6, 7, 8, 9, 10]
}

Transform Maps

variable "users" {
  default = {
    alice   = "admin"
    bob     = "developer"
    charlie = "viewer"
  }
}

locals {
  # Map to list
  user_list = [for name, role in var.users : "${name} is ${role}"]
  # ["alice is admin", "bob is developer", "charlie is viewer"]
  
  # Filter map
  admins = {for name, role in var.users : name => role if role == "admin"}
  # {alice = "admin"}
  
  # Transform map values
  upper_roles = {for name, role in var.users : name => upper(role)}
  # {alice = "ADMIN", bob = "DEVELOPER", charlie = "VIEWER"}
}

Nested For

"What about generating every combination of environments and services?"

Hold my coffee:

variable "environments" {
  default = ["dev", "staging", "prod"]
}

variable "services" {
  default = ["web", "api", "worker"]
}

locals {
  all_combinations = flatten([
    for env in var.environments : [
      for svc in var.services : {
        environment = env
        service     = svc
        name        = "${env}-${svc}"
      }
    ]
  ])
  # [{environment="dev", service="web", name="dev-web"}, ...]
}

String Functions

Terraform has a ton of built-in string functions. Here are the ones you'll actually use.

format

format("Hello, %s!", "World")           # "Hello, World!"
format("Instance %s: %d vCPUs", "i-123", 4)  # "Instance i-123: 4 vCPUs"
format("%06d", 42)                      # "000042"

join / split

join(", ", ["a", "b", "c"])    # "a, b, c"
join("-", ["web", "server"])   # "web-server"

split(",", "a,b,c")            # ["a", "b", "c"]

upper / lower / title

upper("hello")  # "HELLO"
lower("HELLO")  # "hello"
title("hello world")  # "Hello World"

replace

replace("hello world", "world", "terraform")  # "hello terraform"
replace("hello-world", "-", "_")              # "hello_world"

substr

substr("hello world", 0, 5)   # "hello"
substr("hello world", 6, -1)  # "world" (-1 = rest)

trimspace / trim

trimspace("  hello  ")        # "hello"
trim("!!hello!!", "!")        # "hello"
trimsuffix("hello.txt", ".txt")  # "hello"
trimprefix("mr-smith", "mr-")    # "smith"

regex / regexall

regex("[a-z]+", "123abc456")           # "abc"
regexall("[a-z]+", "abc-def-ghi")      # ["abc", "def", "ghi"]

Collection Functions

These are your bread and butter for working with lists and maps.

length

length(["a", "b", "c"])       # 3
length({a = 1, b = 2})        # 2
length("hello")               # 5

element

element(["a", "b", "c"], 0)   # "a"
element(["a", "b", "c"], 3)   # "a" (wraps around! Sneaky!)

index

index(["a", "b", "c"], "b")   # 1

contains

contains(["a", "b", "c"], "b")  # true
contains(["a", "b", "c"], "d")  # false

concat

concat(["a", "b"], ["c", "d"])  # ["a", "b", "c", "d"]

flatten

flatten([["a", "b"], ["c"], ["d", "e"]])  # ["a", "b", "c", "d", "e"]

distinct

distinct(["a", "b", "a", "c", "b"])  # ["a", "b", "c"]

sort / reverse

sort(["c", "a", "b"])         # ["a", "b", "c"]
reverse(["a", "b", "c"])      # ["c", "b", "a"]

slice

slice(["a", "b", "c", "d"], 1, 3)  # ["b", "c"]

compact

compact(["a", "", "b", null, "c"])  # ["a", "b", "c"]

coalesce

coalesce("", null, "hello")    # "hello" (first non-empty)
coalesce(var.custom_ami, data.aws_ami.default.id)

lookup

lookup({a = 1, b = 2}, "a", 0)     # 1
lookup({a = 1, b = 2}, "c", 0)     # 0 (default)

merge

merge({a = 1}, {b = 2}, {c = 3})  # {a = 1, b = 2, c = 3}

# Great for tags — you'll use this ALL the time
locals {
  all_tags = merge(local.common_tags, {
    Name = "my-resource"
  })
}

keys / values

keys({a = 1, b = 2})      # ["a", "b"]
values({a = 1, b = 2})    # [1, 2]

zipmap

zipmap(["a", "b"], [1, 2])  # {a = 1, b = 2}

Numeric Functions

min(1, 2, 3)      # 1
max(1, 2, 3)      # 3
abs(-5)           # 5
ceil(4.3)         # 5
floor(4.7)        # 4
parseint("FF", 16) # 255

Type Conversion

tostring(123)           # "123"
tonumber("123")         # 123
tobool("true")          # true
tolist(toset(["a"]))    # ["a"]
toset(["a", "a"])       # toset(["a"])
tomap({a = "b"})        # {a = "b"}

try

Safely handle errors — the "don't blow up on me" function:

# Returns default if expression fails
try(var.config.optional.nested.value, "default")

# Multiple fallbacks
try(
  var.primary_value,
  var.secondary_value,
  "fallback"
)

can

Check if an expression is valid (without actually crashing):

can(var.config.nested.value)  # true/false

# Use in conditions
locals {
  has_config = can(var.config.database)
  db_host    = local.has_config ? var.config.database.host : "localhost"
}

Encoding Functions

jsonencode / jsondecode

jsonencode({name = "test", count = 5})
# '{"count":5,"name":"test"}'

jsondecode("{\"name\":\"test\"}")
# {name = "test"}

yamlencode / yamldecode

yamlencode({name = "test", items = ["a", "b"]})
# name: test
# items:
#   - a
#   - b

base64encode / base64decode

base64encode("hello")  # "aGVsbG8="
base64decode("aGVsbG8=")  # "hello"

File Functions

Read files from disk — super handy for scripts and templates.

file

# Read file contents
resource "aws_instance" "web" {
  user_data = file("scripts/bootstrap.sh")
}

fileexists

locals {
  use_custom_script = fileexists("scripts/custom.sh")
  script = local.use_custom_script ? file("scripts/custom.sh") : file("scripts/default.sh")
}

templatefile

# template.tftpl
#!/bin/bash
echo "Hello, ${name}!"
echo "Server count: ${count}"

# main.tf
resource "aws_instance" "web" {
  user_data = templatefile("template.tftpl", {
    name  = "World"
    count = 5
  })
}

fileset

# Get all .sh files
fileset("scripts/", "*.sh")
# ["bootstrap.sh", "cleanup.sh"]

# Recursive
fileset("configs/", "**/*.json")

Hash/Crypto Functions

md5("hello")       # "5d41402abc4b2a76b9719d911017c592"
sha256("hello")    # "2cf24dba..."
uuid()             # Random UUID each apply

IP Functions

cidrhost("10.0.0.0/24", 5)        # "10.0.0.5"
cidrnetmask("10.0.0.0/24")        # "255.255.255.0"
cidrsubnet("10.0.0.0/16", 8, 1)   # "10.0.1.0/24"
cidrsubnets("10.0.0.0/16", 8, 8, 8)  # ["10.0.0.0/24", "10.0.1.0/24", "10.0.2.0/24"]

Real-World Examples

Enough theory. Let's see how pros actually use these.

Dynamic Subnet Creation

variable "vpc_cidr" {
  default = "10.0.0.0/16"
}

variable "az_count" {
  default = 3
}

locals {
  azs = slice(data.aws_availability_zones.available.names, 0, var.az_count)
  
  public_subnets = [
    for i, az in local.azs : {
      cidr = cidrsubnet(var.vpc_cidr, 8, i)
      az   = az
      name = "public-${az}"
    }
  ]
  
  private_subnets = [
    for i, az in local.azs : {
      cidr = cidrsubnet(var.vpc_cidr, 8, i + 100)
      az   = az
      name = "private-${az}"
    }
  ]
}

resource "aws_subnet" "public" {
  for_each = {for subnet in local.public_subnets : subnet.name => subnet}
  
  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value.cidr
  availability_zone = each.value.az
  
  tags = {
    Name = each.value.name
  }
}

Environment-Specific Configuration

variable "environment" {
  type = string
}

locals {
  env_config = {
    dev = {
      instance_type = "t2.micro"
      min_size      = 1
      max_size      = 2
      multi_az      = false
    }
    staging = {
      instance_type = "t2.small"
      min_size      = 2
      max_size      = 4
      multi_az      = false
    }
    prod = {
      instance_type = "t2.medium"
      min_size      = 3
      max_size      = 10
      multi_az      = true
    }
  }
  
  config = local.env_config[var.environment]
}

resource "aws_launch_template" "web" {
  instance_type = local.config.instance_type
}

resource "aws_autoscaling_group" "web" {
  min_size = local.config.min_size
  max_size = local.config.max_size
}

Building IAM Policy

"How do I grant S3 access to multiple buckets dynamically?"

With a for expression:

variable "bucket_names" {
  default = ["logs", "data", "backups"]
}

locals {
  s3_arns = [
    for name in var.bucket_names : "arn:aws:s3:::${name}"
  ]
  
  s3_object_arns = [
    for name in var.bucket_names : "arn:aws:s3:::${name}/*"
  ]
}

data "aws_iam_policy_document" "s3_access" {
  statement {
    actions = ["s3:ListBucket"]
    resources = local.s3_arns
  }
  
  statement {
    actions = ["s3:GetObject", "s3:PutObject"]
    resources = local.s3_object_arns
  }
}

What's Next?

You've just leveled up big time. You now know:

  • Conditionals (ternary operator and the count trick)
  • For expressions for transforming anything
  • A whole arsenal of string, collection, and file functions
  • Real-world patterns that actually show up in production

Now it's time to stop copy-pasting code between projects. Let's learn about modules — reusable infrastructure components. Let's go!