Expressions & Functions
Master Terraform's built-in functions and expression syntax
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!