Skip to content
>_higan
This post has a translation:Read in Tiếng Việt

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 name

Operators

# 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 / setsubtract

Numeric 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)        # 255

Encoding & 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 updates

Filesystem & 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() and can() 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_state creates tight coupling between projects. Some teams prefer querying resources directly (e.g. aws_vpc with 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_on when 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 graphviz

4.5. Common pitfalls

ProblemExplanation
Overusing depends_onReduces parallelism, slows things down. Only use when necessary.
Dependency cyclesA depends on B, B depends on A → Terraform reports "Cycle." Restructure.
Data source reading an uncreated resourceData 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:

  1. Lab 1 — Explore 20+ functions using terraform console.
  2. Lab 2 — Real data transformations: subnet splitting, name formatting, tag merging, template rendering.
  3. Lab 3 — Dependency graph: implicit vs explicit, create and fix a dependency cycle.

✅ Module 04 completion criteria

  • Comfortable using terraform console to 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_on is actually needed.

➡️ Next: Module 05 — Loops & Conditionals

Share: