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:
| Function | Purpose |
|---|---|
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.