Skip to content
intermediate Phase 2 · HCL Syntax and Expression Language Deep Dive

String Interpolation and Templates

Embedding expressions inside quoted strings and choosing concatenation alternatives.

40m
0 problems
Topic Progress 0%

${...}: What a Dollar Sign and Braces Actually Do

A quoted string in HCL is a template, and the sequence ${ switches from literal text to expression evaluation:

locals {
  env        = "prod"
  bucket     = "acme-${var.env}-logs"        # "acme-prod-logs"
  fqdn       = "${var.service}.${var.domain}" # several interpolations per string
  subnet_cidr = cidrsubnet(var.vpc_cidr, 4, 2) # plain attribute: no string needed
}

The engine evaluates the expression inside the braces, converts the result to a string, and splices it in. Two behaviors separate the beginner from the fluent. First, unwrapping: a template that consists of nothing but one interpolation — "${var.port}" — is returned verbatim, without string conversion, so it keeps its original type (a number stays a number). The moment any literal text surrounds it ("port-${var.port}"), the whole thing becomes a string. That asymmetry explains diffs that change port = "80" into port = 80 after someone "cleaned up" quotes: with no wrapper text, the quotes were doing a real conversion. The style guide's advice follows directly: in an attribute that takes a number, write var.port; reserve "${var.x}" for templates where interpolation genuinely sits inside text.

Second, escaping. ${ is meaningful everywhere in a template, so text that legitimately contains it — a shell script, a JSON policy with ${...} placeholders of its own, a README — must escape the introducer by doubling its first character: $${literal} produces the literal four characters ${literal}, and %%{ does the same for the directive introducer. Backslash escapes (\n, \t, \", \\) work in quoted strings but not in heredocs, where backslashes are literal — a trap you will meet head-on in Heredoc and Slash-slash String Literals. Everything else in a quoted string is literal: a real newline cannot appear in a quoted string at all, and trying produces "Unfinished multi-line string"; multi-line content is the heredoc's job.

A quick way to experiment with all of this is terraform console:

> "v${1 + 1}x"
"v2x"
> "${true}"
true              # unwrapped: the raw bool comes back
> "prefix ${true}"
"prefix true"     # wrapped: stringified
> "$${1+1}"
"${1+1}"          # escaped: no evaluation

Interpolations may call the whole function library, which turns strings into computed values rather than pasted ones: "logs/${formatdate("YYYY-MM-DD", timestamp())}", "arn:aws:s3:::${var.bucket}/*", or a CIDR derived per subnet with cidrsubnet(var.vpc_cidr, 4, count.index). Interpolations also nest: "${var.prefix}-${join("-", [var.a, var.b])}" is legal and common, though anything past one level of nesting usually belongs in a locals block with a name — named intermediate values are what make the next reader (and the next plan diff) able to follow the computation. One caution joins the perpetual-diff warning from earlier: any non-deterministic function inside a template — timestamp(), uuid(), random_string resources referenced before their value is stored — changes on every evaluation, so the string differs on every plan and Terraform forever proposes an update. When you see a diff whose only content is a timestamp, that is the mechanism, and the fix is to stop evaluating time-dependent values in configuration attributes.

The mental model to carry into every file: a quoted string is literal text with embedded ${expression} evaluations and %{directive} control flow, each escapeable by doubling the leading character. That control flow is where conditionals live, and it is one keystroke away.

Ternaries Inside Strings, and the %{if} Alternative

Conditional logic in a string has two spellings. Inside ${...} you write a full ternary — predicate, question mark, true branch, colon, false branch:

locals {
  access_mode = var.read_only ? "readonly" : "readwrite"
  bucket_name = "acme-${var.env == "prod" ? "production" : "sandbox"}-artifacts"
  kms_suffix  = var.encrypt ? "-encrypted" : ""
}

The ternary obeys strict rules, and each one has a signature error. Both branches are mandatory — var.x ? "yes" fails with "Missing false expression in conditional"; HCL will not default the false side to anything. The branches must unify to a single type: cond ? 1 : "two" is rejected because number and string cannot be unified, while cond ? null : "existing" is accepted because null unifies with anything. And the predicate must be a bool — var.count ? ... errors rather than treating a nonzero number as true, the Python habit HCL refuses to indulge. One asymmetry worth knowing for conditionals over possibly-empty collections: because branches are lazily evaluated only when selected, length(local.items) > 0 ? local.items[0] : "default" does not blow up when the list is empty — the index expression in the unselected branch is never evaluated.

When the branches carry text rather than values, the %{if} template directive is usually cleaner — it is the string-native conditional, with an optional else and a mandatory endif:

locals {
  notice = "This bucket %{if var.env == "prod"}IS PRODUCTION%{else}is non-production%{endif}."
}

Directives nest, and a %{for} sibling iterates — the right tool for building a list of lines from a collection. Both directives accept strip markers: a ~ adjacent to the braces swallows the surrounding whitespace, which is what keeps generated files from filling up with blank lines:

%{ for ip in aws_instance.example[*].private_ip ~}
server ${ip}
%{ endfor ~}

Without the tildes, every directive's own newline lands in the output; with them, one clean line per instance. The ~ works on either side of any interpolation or directive — %{~ endif } strips preceding whitespace just as %{endif ~} strips following whitespace — and it operates on the template text, not on values: stripping can never remove a space that came from an interpolated variable, which is a subtle distinction the spec spells out precisely.

Choosing between the two conditional spellings comes down to what the branches contain. Value selection inside a larger template — one word or another — is a ternary inside ${...} and keeps the expression visible. Text-shaped logic — sentences with clauses, multiple lines, embedded punctuation — is %{if}, because nesting ternaries inside a string to express prose produces unreadable quotation marks and unbalanced quotes being the most common string bug in review. The practical habit: glance at the finished value with terraform console or terraform plan after writing any conditional template; generated text is cheap to check there and expensive to discover in a rendered config file at 2 a.m. Choosing the string-assembly tool for anything larger than a ternary is the next chapter's job — including the answer to the popular myth that adjacent strings "just join".

No Plus Operator: Assembling Strings With format, join, and templatefile

Ask a newcomer to concatenate two strings in HCL and they write "a" + "b", because every language they know has +. HCL does not: arithmetic operators apply only to numbers, so "a" + "b" fails with an operand-type error. The second guess — putting the strings on adjacent lines — fails too, and this is worth seeing once so it never confuses you again:

locals {
  name = "acme"
         "-logs"    # NOT concatenation: the newline ended the argument, so this line
                    # parses as a new body item -> "Argument or block definition required"
}

HCL has no implicit string joining and no concatenation operator, by design: the language prefers one obvious assembly mechanism per job, and there are four of them. For a one-off composition with placeholders, format is printf with better ergonomics:

format("%s-%02d", var.base, var.index)     # "web-01"
format("%[2]s/%[1]s", "a", "b")            # "b/a" — indexed verbs reorder arguments
format("100%% sure")                        # "100% sure" — %% escapes the verb marker

The numbered-verb form (%[2]s) is the quiet superpower: it lets you reuse and reorder arguments instead of juggling placeholder order. For a collection of pieces, join takes a delimiter and any list or tuple:

join(",", aws_subnet.private[*].id)        # splat + join: the classic pairing

When the same format applies element by element, formatlist maps a format over a list and returns a list — the idiom for turning a list of IPs into hostnames:

formatlist("ip-%s.internal", ["10.0.0.4", "10.0.0.5"])
# ["ip-10.0.0.4.internal", "ip-10.0.0.5.internal"]

And for anything longer than a line — a cloud-init script, an nginx config, a policy document — stop assembling strings entirely: put the document in a .tftpl file with ${} placeholders and read it with templatefile("user_data.tftpl", { version = var.app_version }). The file gets syntax highlighting, linting, and multi-line editing; the template keeps real interpolation. templatefile takes any collection values you pass, so a %{for} loop inside the file can render a list of hosts exactly as an inline heredoc would — with the advantage that the document lives where text editors can actually help you. That fourth tool also answers the question of embedded documents: for JSON and YAML specifically, HashiCorp's own guidance is to prefer jsonencode() and yamlencode() over hand-written documents, because Terraform then guarantees the output is valid JSON regardless of quoting hazards.

One assembly case remains because it appears in nearly every real configuration: building a map from a list, usually to name things. The for expression is the tool, and it produces an object directly:

{ for id in aws_subnet.private[*].id : id => "subnet-${id}" }
# { "subnet-aaa" = "subnet-subnet-aaa", ... }

Pairs like this — format for text, join/formatlist for lists, for for maps, templatefile for documents — are the entire string-assembly toolkit. Reach for them in that order and you will never again find yourself fighting HCL for an operator it deliberately does not have. One last debugging shortcut: when a computed string comes out wrong, build it one piece at a time in terraform console — paste the format(...) call alone, confirm the fragment, then put it back in context; assembly bugs are fifty-line mysteries outside the console and ten-second experiments inside it. The next topic covers the multi-line literals themselves — heredocs — which underpin all of it.

Quiz

1. The expression `"${var.port}"` is assigned to an argument expecting a number, where var.port = "8080". What happens?

Question 1 options

2. What is required to write `${1+1}` literally inside a quoted string?

Question 2 options

3. `var.enabled ? "on"` fails with "Missing false expression in conditional". What else is true of HCL ternaries?

Question 3 options

4. You need a comma-separated string of subnet IDs from aws_subnet.private[*].id. Which construct is correct?

Question 4 options

Flashcards

Question

What does ${...} do in a quoted string, and what is unwrapping?

Answer

It switches from literal text to expression evaluation: the expression's result is converted to string and spliced in. Unwrapping is the special case where a template is exactly one interpolation with no surrounding text — the original value and type come back unchanged, which is why `"${var.port}"` can still satisfy a number argument.

Question

How do you write a literal ${ or %{ in a template?

Answer

Double the leading character: `$${` produces `${`, `%%{` produces `%{`. This is the only escaping mechanism that works in both quoted strings and heredocs; backslash escapes (\n, \t, \") work only in quoted strings.

Question

State the three rules of HCL ternaries and one error message each.

Answer

Both branches are mandatory ("Missing false expression in conditional"); branches must unify to a single type (number vs string is rejected); the predicate must be a real boolean — no truthiness. Branches evaluate lazily, so indexing a possibly-empty list in the unselected branch is safe.

Question

Why do "a" + "b" and adjacent-line strings both fail, and what are the four assembly tools instead?

Answer

Arithmetic operators accept numbers only, and a newline terminates an argument (adjacent strings parse as a new, invalid body item). Use: `format` (printf-style, `%[2]s` reorders), `join` (delimiter + list), `formatlist` (format per element), and `templatefile` (a real file with ${} placeholders) — plus jsonencode/yamlencode for documents.

Question

When should you prefer a bare reference over "${var.x}" in an attribute?

Answer

Whenever the argument is not a string template — numbers, lists, objects — because unwrapping makes the quotes a no-op and bare references state the type honestly; the style guide reserves "${...}" for strings with surrounding literal text. In multi-branch logic inside text, heredocs with %{if} beat nested ternaries for readability.

Revision Notes

Key Takeaways

  • 1. Quoted strings are templates: `${expr}` evaluates, unwrapping preserves type for single-interpolation strings, and any literal text forces stringification
  • 2. `$${` and `%%{` are the universal escapes; backslash escapes belong to quoted strings only, and newlines never appear in them
  • 3. Ternaries are strict: both branches, unified types, real booleans — with lazy evaluation that makes guarded indexing safe
  • 4. HCL deliberately has no string concatenation: `+` is numbers-only and adjacent strings are a parse error, so assembly goes through format/join/formatlist/templatefile
  • 5. Prefer bare references in non-string arguments; reserve interpolation for genuine templates, and prefer `jsonencode` over hand-built JSON documents

Interview Tips

  • • "How do you concatenate strings in Terraform?" — you don't, there is no operator: `format`, `join`, `templatefile`; mention unwrapping and that `${}` inside larger strings is interpolation, not concatenation syntax
  • • "Why does my plan keep wanting to update a string argument?" — check for non-deterministic expressions inside templates (timestamp(), uuid()); the interpolated result changes every plan, creating a perpetual diff
  • • "Explain template escaping in a payload that contains its own ${}." — double the introducer: `$${...}` renders literal `${...}` in both quoted strings and heredocs; that is the cloud-init / Lambda-policy trap
  • • "Ternary or %{if} in strings?" — ternary for value selection inside a larger interpolation; `%{if}` for text-shaped segments, ideally inside a heredoc with `~` strip markers for clean output

Cheat Sheet

Strings & Templates

Interpolation: "acme-${var.env}-logs" · single-interpolation templates unwrap (keep type) · anything wrapped stringifies · terraform console to test.

Escapes: $${ → ${ · %%{ → %{ · backslashes (\n, \t, \", \\) quoted strings only — never heredocs. No real newlines in quoted strings ("Unfinished multi-line string").

Conditionals:

# ternary — both branches required, must unify, strict bool predicate
name  = var.ro ? "readonly" : "readwrite"
# directive — text-shaped logic; ~ strips adjacent whitespace
msg = "%{if var.env == "prod"}PROD%{else}non-prod%{endif}"

Assembly (no + operator, no implicit joining):

Tool Use
format("%s-%02d", a, b) one-off composition; %[2]s reorders/reuses
join(",", list) delimiter-joined string from a list/tuple
formatlist("ip-%s.internal", list) format applied per element → list
templatefile("f.tftpl", {…}) multi-line documents with interpolation
jsonencode({...}) / yamlencode guaranteed-valid JSON/YAML instead of hand-written documents

Continue Learning