Skip to main content
RunBook Academy

TerraformIV · HCL: The Terraform Configuration LanguageProduction Terraform

Expressions and References

Foundation⏱ ~12 minbash

What you'll learn

  • Read and write an HCL expression in any of the five common shapes
  • Resolve `a.b.c` style references against resources, modules, and locals
  • Interpolate values into strings with `${ ... }` without tripping the parser
  • Coerce between types at the right boundary using `tonumber`, `tostring`, `tolist`, `toset`, `tomap`
  • Recognise the difference between a literal and an expression when the value is computed

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

Most HCL is argument-and-expression pairs. The argument name on the left is yours to choose; the expression on the right has to be something Terraform can evaluate at plan time. Production failures rarely come from unfamiliar block kinds — they come from expressions that resolve to the wrong value at the wrong moment. This lesson is the rulebook for the right-hand side.

The five shapes an expression can take

Anywhere the parser expects an expression, one of these fits:

ShapeExampleWhat the parser sees
Literal3, "eu-west-1", true, null, [1, 2]The value directly
Referencevar.region, aws_vpc.main.id, local.common_tagsA name resolved against a scope
Function callupper(var.env), lookup(map, key, "")A built-in or user-supplied function
For expression[for s in var.list : upper(s)], { for k, v in m : k => v }A transformed collection
Conditionalcondition ? a : bOne of two branches
Operatora + b, a && b, !aAn arithmetic or boolean operator
String interpolation"prefix-${var.region}-suffix"A template with ${ ... } sub-expressions

A more complex expression is any combination of the above:

tags = merge(
  local.common_tags,
  { Name = "web-${var.environment}-${count.index}" },
)

count = var.replicas > 0 ? var.replicas : 0

Note that count = var.replicas > 0 ? var.replicas : 0 is a single expression composed of an operator, then a conditional, with two references on either side of the colon.

Reference resolution: dotted paths

A reference is a dotted path that the parser resolves against a scope. The scopes are:

Reference prefixResolves toExample
var.Module input variablesvar.region
local.Values declared in a locals blocklocal.common_tags
data.Read-only attribute tree from a data blockdata.aws_ami.debian_12.id
path.Filesystem path module attributespath.module
terraform.Attributes of the running CLI and workspaceterraform.workspace
each.Key and value of a for_each iterationeach.key
count.Integer index in a count iterationcount.index
self.Current resource’s own attributesself.id
<type>.<name>Address of a resource blockaws_instance.web.id
module.<name>An output of a child modulemodule.network.vpc_id

The grammar is strict:

RESOURCE_ADDR = "<TYPE>" "<NAME>"
COUNT_REF     = RESOURCE_ADDR [ "<INTEGER>" ]
FOREACH_REF   = RESOURCE_ADDR [ "<KEY>" ]

A bare resource reference (aws_instance.web) is a set of all instances — usually legal only when there is exactly one of them. A count.index reference (aws_instance.web[0].id) pins to one. A for_each reference (aws_instance.web["primary"].id) does the same by key.

String interpolation

Anything double-quoted in HCL is a string, but a string can hold ${ ... } sub-expressions:

name  = "web-${var.environment}-${count.index}"
arn   = "arn:aws:s3:::${var.bucket_name}"
query = "SELECT * FROM ${var.table} WHERE id = ${var.user_id}"

Three rules:

  1. The braces are required. "$var.region" is the literal string "$var.region", not an interpolation.
  2. The expression inside must actually evaluate. A function call, a reference, a conditional — anything that produces a value is allowed.
  3. The interpolated value is rendered with its default Go string format. Numbers render with no quotes, lists render as [a, b, c], maps render as {"a"="1", "b"="2"}. If you need JSON or YAML, call jsonencode / yamlencode explicitly.

Two operators you might meet in interpolation are % (printf-style) and $$ (literal $). They are rarely used.

message = format("Resource %s created at %s", self.name, timestamp())

Function calls and the order they are evaluated

A function call is name(arg1, arg2, ...). Arguments are evaluated left-to-right, each in its own expression context. Functions fall into several families:

FamilyExamplesWhen to use
Numericmax, min, floor, ceil, absCapacity numbers, time conversions
Stringupper, lower, trimspace, substr, replace, formatNaming, formatted logs
Collectionlength, concat, flatten, merge, distinctList and map manipulation
Type conversiontostring, tonumber, tolist, toset, tomapThe boundary where two pipelines meet
Encodingjsonencode, jsondecode, yamlencode, yamldecode, base64encodeInterfacing with non-Terraform systems
Filesystemfile, fileexists, templatefileLoading external files
Date/timetimestamp, formatdate, timeaddTagging, IAM policies
Networkcidrsubnet, cidrhost, cidrnetmaskVPCs, subnets, peering
Iterationelement, lookup, coalesce, coalescelistPer-entry selection and defaulting

Function calls are eager at plan time. They run during the configuration walk, before any provider call. If a function fails, the offending resource or module is omitted from the plan and a diagnostic is emitted.

The literal-vs-expression boundary

A literal is a value written directly: "eu-west-1", 3, true, [1, 2, 3]. An expression is anything else: a reference, a function call, a conditional.

# literal
availability_zone = "eu-west-1a"

# expression
availability_zone = "eu-west-1${var.az_letter}"   # interpolated
availability_zone = var.primary_az                  # reference
availability_zone = lookup(var.az_map, var.env, "eu-west-1a")  # function call

What is the practical difference? Two things matter:

  1. Whether Terraform marks the attribute as “known”. A literal is known at parse time. An interpolated string that depends on var.foo becomes known only when terraform plan resolves the variables.
  2. Whether the value changes when the input changes. A literal instance_type = "t3.small" will never trigger a replacement on its own. instance_type = var.instance_type will. The diff shows which is which.

Type conversion at the boundary

Most production expressions live in a single type, but the boundaries between Terraform and the outside world do not. Three common boundaries:

Boundary 1: provider input wants one type, configuration produces another

# variable is string from -var-file
# provider wants an integer
replicas = tonumber(var.replicas)

Use the type-conversion functions (tonumber, tostring, tolist, toset, tomap) at the precise line where the boundary occurs, not “globally” in a locals block. The boundary is where the type changes; the conversion should live there too.

Boundary 2: data source returns generic, configuration expects specific

# data "external" returns a JSON-encoded string
result = jsondecode(data.external.api.result)["status"]

data "external" is a provider that returns string-only attributes; the actual JSON value is parsed by jsondecode at the use site. Decoding at the use site, not in a local, keeps the type information close to the consumer.

Boundary 3: structural downcast

# I have list(object({...})); I need map(string)
tag_map = tomap({
  for s in var.services : s.name => s.replicas
})

The shorthand { for ... in ... : k => v } is a map for-expression, not a type conversion. tomap() only re-tags an already-pair-shaped value. Both forms have their place.

Production failure modes

  1. var.x typo in a reference. var.regoin instead of var.region. Symptom: Error: Reference to undeclared input variable. Recovery: search the file (grep -n 'var.regoin') and fix.
  2. Interpolation that produces a string when the provider wants a number. "${var.port}" in a port = argument. Symptom: type mismatch. Recovery: pass var.port directly (it is already a number).
  3. Interpolation that drops a list element’s quoting. "${var.subnets}" produces "[subnet-a, subnet-b]". Many APIs want a CSV or JSON array. Use join(",", var.subnets) or jsonencode(var.subnets) instead.
  4. Bare resource reference with count > 1. tags = aws_instance.web.tags when aws_instance.web is plural. Symptom: provider reports “expected a string, got a list”. Recovery: index with count.index, each.key, or iterate with a for-expression.
  5. lookup with the wrong default type. lookup(var.tags, "Name", null) when the rest of the expression expects a string. Symptom: type mismatch at the next call. Recovery: pass "" and let the call site handle empty strings.
  6. timestamp() inside a tag. Every plan produces a fresh timestamp; every apply triggers an update. Never use timestamp() in a tag or in another attribute that drives resource identity.

Recovery procedure

  1. terraform console and paste the offending expression in. Inspect the type and the value.
  2. If the type is wrong, add the right conversion at the boundary.
  3. If the value is right but the provider rejects it, look up the provider attribute in terraform providers schema -json.
  4. Re-run terraform validate.
  5. Re-run terraform plan -refresh=false to confirm no real-world state changed in the process.

References

What comes next

The next lesson is Functions for Production Code: which functions are worth memorising, which combinations are dangerous, and which to never write because there is a better native construct.

Verification

terraform console <<< 'var.region'
terraform validate
terraform plan -refresh-only

Expected:

$ terraform console <<< 'var.region'
"eu-west-1"

If terraform console returns nothing or emits a parse error, the expression is malformed. The most common cause is unbalanced ${ ... } inside a string.

Knowledge check · 7 questions

  1. Q1. Which is a correctly written HCL expression that interpolates a variable into a string?

  2. Q2. A resource `aws_instance.web` uses `count = 3`. Which reference picks a single instance?

  3. Q3. Inside an expression, a function call is evaluated lazily: only when the resource it belongs to is about to be created.

  4. Q4. You have `var.port` declared as `number` and want to use it inside a string for a tag. Which is correct?

  5. Q5. Which three of these reference prefixes are resolved against Terraform scopes?

  6. Q6. A provider attribute expects a list. `var.subnets` is a tuple `(string, string)`. The right conversion is?

  7. Q7. A `tags` argument is set to `aws_instance.web.tags`. The apply fails with "expected a map of strings, got set of maps". Most likely cause?

Passing score: 75%. Answers are checked in this browser.