TerraformIV · HCL: The Terraform Configuration LanguageProduction Terraform
Expressions and References
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
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:
| Shape | Example | What the parser sees |
|---|---|---|
| Literal | 3, "eu-west-1", true, null, [1, 2] | The value directly |
| Reference | var.region, aws_vpc.main.id, local.common_tags | A name resolved against a scope |
| Function call | upper(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 |
| Conditional | condition ? a : b | One of two branches |
| Operator | a + b, a && b, !a | An 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 prefix | Resolves to | Example |
|---|---|---|
var. | Module input variables | var.region |
local. | Values declared in a locals block | local.common_tags |
data. | Read-only attribute tree from a data block | data.aws_ami.debian_12.id |
path. | Filesystem path module attributes | path.module |
terraform. | Attributes of the running CLI and workspace | terraform.workspace |
each. | Key and value of a for_each iteration | each.key |
count. | Integer index in a count iteration | count.index |
self. | Current resource’s own attributes | self.id |
<type>.<name> | Address of a resource block | aws_instance.web.id |
module.<name> | An output of a child module | module.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:
- The braces are required.
"$var.region"is the literal string"$var.region", not an interpolation. - The expression inside must actually evaluate. A function call, a reference, a conditional — anything that produces a value is allowed.
- 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, calljsonencode/yamlencodeexplicitly.
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:
| Family | Examples | When to use |
|---|---|---|
| Numeric | max, min, floor, ceil, abs | Capacity numbers, time conversions |
| String | upper, lower, trimspace, substr, replace, format | Naming, formatted logs |
| Collection | length, concat, flatten, merge, distinct | List and map manipulation |
| Type conversion | tostring, tonumber, tolist, toset, tomap | The boundary where two pipelines meet |
| Encoding | jsonencode, jsondecode, yamlencode, yamldecode, base64encode | Interfacing with non-Terraform systems |
| Filesystem | file, fileexists, templatefile | Loading external files |
| Date/time | timestamp, formatdate, timeadd | Tagging, IAM policies |
| Network | cidrsubnet, cidrhost, cidrnetmask | VPCs, subnets, peering |
| Iteration | element, lookup, coalesce, coalescelist | Per-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:
- Whether Terraform marks the attribute as “known”. A literal is known at parse time. An interpolated string that depends on
var.foobecomes known only whenterraform planresolves the variables. - 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_typewill. 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
var.xtypo in a reference.var.regoininstead ofvar.region. Symptom:Error: Reference to undeclared input variable. Recovery: search the file (grep -n 'var.regoin') and fix.- Interpolation that produces a string when the provider wants a number.
"${var.port}"in aport =argument. Symptom: type mismatch. Recovery: passvar.portdirectly (it is already a number). - Interpolation that drops a list element’s quoting.
"${var.subnets}"produces"[subnet-a, subnet-b]". Many APIs want a CSV or JSON array. Usejoin(",", var.subnets)orjsonencode(var.subnets)instead. - Bare resource reference with
count > 1.tags = aws_instance.web.tagswhenaws_instance.webis plural. Symptom: provider reports “expected a string, got a list”. Recovery: index withcount.index,each.key, or iterate with a for-expression. lookupwith 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.timestamp()inside a tag. Every plan produces a fresh timestamp; every apply triggers an update. Never usetimestamp()in a tag or in another attribute that drives resource identity.
Recovery procedure
terraform consoleand paste the offending expression in. Inspect the type and the value.- If the type is wrong, add the right conversion at the boundary.
- If the value is right but the provider rejects it, look up the provider attribute in
terraform providers schema -json. - Re-run
terraform validate. - Re-run
terraform plan -refresh=falseto confirm no real-world state changed in the process.
References
- HashiCorp, “Expressions” — https://developer.hashicorp.com/terraform/language/expressions
- HashiCorp, “References to Resource Attributes” — https://developer.hashicorp.com/terraform/language/expressions/references
- HashiCorp, “Function Reference” — https://developer.hashicorp.com/terraform/language/functions
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
Q1. Which is a correctly written HCL expression that interpolates a variable into a string?
Q2. A resource `aws_instance.web` uses `count = 3`. Which reference picks a single instance?
Q3. Inside an expression, a function call is evaluated lazily: only when the resource it belongs to is about to be created.
Q4. You have `var.port` declared as `number` and want to use it inside a string for a tag. Which is correct?
Q5. Which three of these reference prefixes are resolved against Terraform scopes?
Q6. A provider attribute expects a list. `var.subnets` is a tuple `(string, string)`. The right conversion is?
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.