Terraform #04 — Expressions, Functions & Data Sources
Expressions, ~150 built-in functions, data sources, and how Terraform's dependency graph works.
Goal: Get comfortable with expressions and built-in functions for transforming data, use data sources to query existing infrastructure, and understand how Terraform's dependency graph works.
4.1. Expressions
References
var.name # input variable
local.name # local value
resource_type.name.attr # resource attribute (e.g. aws_instance.web.id)
data.type.name.attr # data source attribute
module.name.output # output from a child module
path.module # path of the current module directory
path.root # path of the root module
terraform.workspace # current workspace nameOperators
# Arithmetic
a + b, a - b, a * b, a / b, a % b
# Comparison
a == b, a != b, a < b, a <= b, a > b, a >= b
# Logic
a && b, a || b, !a
# Ternary — you'll use this constantly
var.env == "prod" ? "t3.large" : "t3.micro"String templating
"${var.name}-server" # interpolation
"Hello ${upper(var.name)}!" # call a function inside a string
# Heredoc for multi-line strings
content = <<-EOT
line 1: ${var.a}
line 2: ${var.b}
EOT
# Directives for conditionals and loops inside templates
"%{ if var.enabled }enabled%{ else }disabled%{ endif }"
"%{ for ip in var.ips }server ${ip}\n%{ endfor }"4.2. Built-in functions
Terraform ships with ~150 functions. You can't define custom functions (except with Terraform 1.8+ provider functions). Here are the ones that actually come up in day-to-day work:
String functions
upper("abc") # "ABC"
lower("ABC") # "abc"
title("hello world") # "Hello World"
trimspace(" x ") # "x"
trimprefix("abc-x", "abc-") # "x"
trimsuffix("x.txt", ".txt") # "x"
substr("hello", 0, 3) # "hel"
replace("a-b-c", "-", "_") # "a_b_c"
split(",", "a,b,c") # ["a","b","c"]
join("-", ["a","b"]) # "a-b"
format("%s-%03d", "web", 7) # "web-007"
formatlist("%s.example.com", ["a","b"]) # ["a.example.com","b.example.com"]
regex("[0-9]+", "abc123") # "123"
regexall("[0-9]+", "a1b2") # ["1","2"]Collection functions (list/map/set)
length([1,2,3]) # 3
concat([1,2],[3,4]) # [1,2,3,4]
contains(["a","b"], "a") # true
keys({a=1, b=2}) # ["a","b"]
values({a=1, b=2}) # [1,2]
lookup({a=1}, "a", 0) # 1
lookup({a=1}, "z", 99) # 99 (falls back to default)
merge({a=1}, {b=2}) # {a=1, b=2}
flatten([[1,2],[3]]) # [1,2,3]
distinct([1,1,2]) # [1,2]
sort(["c","a","b"]) # ["a","b","c"]
element(["a","b","c"], 1) # "b"
slice(["a","b","c"], 0, 2) # ["a","b"]
toset(["a","a","b"]) # set {a, b}
zipmap(["a","b"], [1,2]) # {a=1, b=2}
setunion / setintersection / setsubtractNumeric functions
min(1,2,3) / max(1,2,3)
abs(-5) # 5
ceil(1.2) / floor(1.8) # 2 / 1
pow(2, 10) # 1024
parseint("ff", 16) # 255Encoding & hashing
jsonencode({a=1}) # "{\"a\":1}"
jsondecode("{\"a\":1}") # {a=1}
yamlencode({a=1})
base64encode("hi") / base64decode(...)
md5("x") / sha256("x") / sha512("x")
filesha256("file.txt") # file hash — useful for triggering updatesFilesystem & misc
file("${path.module}/script.sh") # read file content
templatefile("tpl.tftpl", { name = "x" }) # render a template with variables
fileexists("path")
abspath(path.root)
coalesce(null, "", "x") # "x" — first non-null, non-empty value
coalescelist([], [1,2]) # [1,2]
try(var.maybe.value, "default") # catch errors, return default
can(expr) # returns true/false instead of erroring (used in validation)
nonsensitive(x) # strip sensitive marking (use carefully)
timestamp() # current time (RFC3339)
timeadd(timestamp(), "24h")
uuid() / uuidv5(...)
cidrsubnet("10.0.0.0/16", 8, 2) # "10.0.2.0/24" — subnet splitting
cidrhost("10.0.0.0/24", 5) # "10.0.0.5"
try()andcan()are your safety nets when dealing with data that might not exist — you'll use them a lot in more complex projects.
Provider-defined functions (Terraform 1.8+): some providers ship their own functions, called as
provider::aws::arn_parse(...).
The console — try functions interactively
terraform console
> upper("hi")
"HI"
> cidrsubnet("10.0.0.0/16", 8, 5)
"10.0.5.0/24"
> [for i in range(3) : i * 2]
[0, 2, 4]terraform console is the fastest way to test an expression before writing it into code.
4.3. Data sources — querying existing infrastructure
A data source reads information without creating, modifying, or deleting anything. Use them to:
- Get the latest AMI instead of hardcoding an ID.
- Reference a VPC or subnet owned by another team.
- Get the current account ID and region.
- Read outputs from another state file (
terraform_remote_state).
# Current account and region (extremely useful)
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}
# Available AZs in the current region
data "aws_availability_zones" "available" {
state = "available"
}
# Latest Ubuntu AMI
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
# Use the results
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
availability_zone = data.aws_availability_zones.available.names[0]
tags = {
Account = data.aws_caller_identity.current.account_id
Region = data.aws_region.current.name
}
}terraform_remote_state — sharing data between projects
One project reads the outputs of another (e.g. an app project reads the VPC ID from a network project):
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "my-tfstate"
key = "network/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "web" {
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_id
}⚠️
terraform_remote_statecreates tight coupling between projects. Some teams prefer querying resources directly (e.g.aws_vpcwith tag filters) to reduce this dependency. Both approaches are common — know the trade-off.
4.4. Dependency graph and execution order
Terraform builds a directed acyclic graph (DAG) from your references. This graph determines the order resources are created or destroyed, and which ones can run in parallel.
Implicit dependency — prefer this
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "web" {
vpc_id = aws_vpc.main.id # ← reference means Terraform knows subnet depends on vpc
}Explicit dependency — when there's no direct reference
resource "aws_s3_bucket" "data" {
bucket = "my-data"
}
resource "aws_instance" "app" {
# The instance doesn't reference the bucket in any attribute,
# but it needs the bucket to exist first
depends_on = [aws_s3_bucket.data]
}Rule: Always prefer implicit dependency through references. Only use
depends_onwhen Terraform genuinely can't infer the relationship (usually an IAM policy that must exist before an instance can use the role).
Visualize the graph
terraform graph | dot -Tsvg > graph.svg # requires graphviz4.5. Common pitfalls
| Problem | Explanation |
|---|---|
Overusing depends_on | Reduces parallelism, slows things down. Only use when necessary. |
| Dependency cycles | A depends on B, B depends on A → Terraform reports "Cycle." Restructure. |
| Data source reading an uncreated resource | Data sources are read at plan time — if they depend on something that doesn't exist yet, it'll fail. Use depends_on in the data block or split the apply. |
Wrapping everything in try() | Hides real errors. Only use it when a value genuinely might be absent. |
4.6. Hands-on Labs
Go to hands-on/ and follow LAB.md:
- Lab 1 — Explore 20+ functions using
terraform console. - Lab 2 — Real data transformations: subnet splitting, name formatting, tag merging, template rendering.
- Lab 3 — Dependency graph: implicit vs explicit, create and fix a dependency cycle.
✅ Module 04 completion criteria
- Comfortable using
terraform consoleto test expressions interactively. - Know when to reach for
try()/can()/coalesce()/lookup(). - Can use
cidrsubnet()to split a CIDR block into subnets. - Understand data sources and
terraform_remote_state, including the trade-offs. - Can explain the difference between implicit and explicit dependencies, and know when
depends_onis actually needed.
➡️ Next: Module 05 — Loops & Conditionals