Expressions, Functions & Templates

Conditional expressions, the built-in function library you actually reach for, heredoc strings, externalising policy files, and rendering templates with variables and loops.

intermediate 18 min lesson hands-on task included

Terraform’s expression language is small on purpose. There are no user-defined functions — the built-in library is the entire vocabulary — and that constraint keeps configurations readable at the cost of occasionally making you think harder.


Topic 1: Conditional Expressions

The familiar ternary form:

instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"

Both branches must return the same type. Returning a string from one and a number from the other is an error, and it is one of the more common expression mistakes.

Combined with count, this becomes conditional resource creation:

resource "aws_lb" "public" {
  count = var.create_load_balancer ? 1 : 0
  name  = "public-lb"
}

Topic 2: The Functions You Actually Use

Over a hundred exist. These are the ones that earn their place:

FunctionPurpose
lookup(map, key, default)Safe map access with fallback
coalesce(a, b, ...)First non-null value — the standard null guard
length(collection)Size of list, map or string
element(list, index)Item at index (zero-based)
join(sep, list) / split(sep, str)List ↔ string
merge(map, map)Combine maps — how tag defaults get overridden
cidrsubnet(prefix, newbits, netnum)Derive a subnet CIDR
toset() / tolist() / tomap()Type conversion
flatten() / zipmap()Collection reshaping
jsonencode() / jsondecode()Build JSON without string concatenation
file(path)Read a file as a string
templatefile(path, vars)Render a template
filebase64sha256(path)Content hash — how deployments detect code changes
ami_id      = lookup(var.amis, var.region, var.amis["us-east-1"])
subnet_cidr = cidrsubnet(var.vpc_cidr, 8, count.index)
name        = coalesce(var.override_name, local.default_name)

jsonencode() deserves special mention. Building a policy document by string concatenation means quoting bugs and unbalanced braces. jsonencode() takes an HCL object and produces valid JSON, with interpolation handled properly:

policy = jsonencode({
  Version = "2012-10-17"
  Statement = [{
    Effect   = "Allow"
    Action   = ["s3:GetObject"]
    Resource = "${data.aws_s3_bucket.artifacts.arn}/*"
  }]
})

Topic 3: Multi-Line Strings

Heredoc syntax. The closing marker must start a new line:

user_data = <<-EOF
  #!/bin/bash
  set -ex
  yum update -y
  systemctl start nginx
EOF

The <<- form (with the dash) strips leading whitespace so the block can be indented with the surrounding code. Plain << preserves indentation literally, which is almost never what you want inside a nested block.


Topic 4: Externalising Files

Long inline documents make configuration unreadable. Move them out:

resource "aws_iam_policy" "list_buckets" {
  name   = "list-buckets-policy"
  policy = file("./policy.json")
}

file() reads relative to the current module, so a module referencing its own files should use ${path.module} rather than a bare relative path:

policy = file("${path.module}/policy.json")

Topic 5: Templates

file() reads static content. templatefile() renders it with variables, which is what you need when part of the content is only known at apply time.

resource "aws_ssm_parameter" "user_data" {
  name  = "/userdata/${var.environment}"
  type  = "String"
  value = templatefile("${path.module}/userdata.tpl", {
    bucket      = aws_s3_bucket.artifacts.bucket
    environment = var.environment
  })
}

Inside the template, reference the passed variables with ${name}:

#!/bin/bash
echo "environment=${environment}" > /etc/app.conf
aws s3 cp s3://${bucket}/app.tar.gz /tmp/

Loops in templates:

%{ for addr in ip_addrs ~}
backend ${addr}:${port}
%{ endfor ~}

Rendered with { port = 8080, ip_addrs = ["10.0.0.1", "10.0.0.2"] }:

backend 10.0.0.1:8080
backend 10.0.0.2:8080

The ~ trims surrounding whitespace, which is what stops a loop producing a blank line per iteration.

Templates plus loops are genuinely useful for generating load-balancer configs, policy documents with a variable number of statements, and inventory files for downstream tooling.


Topic 6: terraform console

The most underused command in the toolkit. It evaluates expressions against your actual configuration and state without running a plan:

> length([1, 2, 3])
3
> lookup({a="ay", b="bee"}, "c", "fallback")
"fallback"
> toset(["foo", "bar", "foo"])
toset(["bar", "foo"])
> cidrsubnet("10.0.0.0/16", 8, 3)
"10.0.3.0/24"
> var.availability_zones
tolist(["eu-west-1a", "eu-west-1b"])

When a for_each rejects its input or a conditional returns the wrong type, console tells you the actual shape of the value in seconds. Reach for it before you reach for a plan.


Try it yourself: Open terraform console in a project with state and inspect a resource attribute directly — aws_vpc.main will print every field it knows. That is the fastest way to discover what a resource actually exports.

Common mistake: Building JSON with string interpolation because it looks simpler than jsonencode(). It works until a value contains a quote or a variable renders empty, at which point you are debugging malformed JSON inside a Terraform error message rather than reading a clear type error.