Expressions and Functions
HCL has no loops, no user-defined functions and no statements, and it still needs to transform data —
build a map from a list, apply a default, render a template, produce a policy document. It does that with
expressions: conditionals, for expressions, splats, and about 150 built-in functions of which roughly
fifteen matter. This page is those, plus dynamic blocks, which are the one feature here that is usually
a mistake.
Prerequisites: HCL & the Type System, Variables, Locals & Outputs
What & Why
An expression is anything that produces a value. A function is a built-in transformation you call in an expression. Together they're how a configuration turns its inputs into the exact shapes providers demand — and they are all pure and evaluated at plan time, which is the property that makes them safe.
The bad practice it replaces
String surgery. Without expressions, "take this list of names and build a map of tags for each" is
either repeated by hand or generated by a templating layer outside Terraform — which is how
infrastructure repositories acquire a generate.py that emits .tf files, and with it a build step,
a stale-output problem, and configuration nobody can read.
The other bad practice, more specific and very common, is building structured data with string
concatenation. A JSON policy assembled with join and ${} works until an interpolated value contains
a quote, at which point you have generated invalid JSON — and for a policy document, "invalid" sometimes
means "accepted and wrong". jsonencode over a real object cannot do that.
Where it sits
This is the last of the language topics and the one you'll return to as a reference. It deliberately
stops before the thing you most often use these expressions for: building a map to feed for_each,
which is Meta-Arguments. Provider-contributed functions are
Extending & the Ecosystem.
Three things they're confused with
Expressions are not statements. There's no sequence and no assignment. A for expression produces a
new collection; it doesn't mutate one, and there's no accumulator you can write to.
Functions are pure. They cannot read state, call an API, or have side effects. file() reads from
disk at plan time, which is the closest thing to an exception and is still deterministic within a run.
dynamic is not a loop over resources. It generates repeated nested blocks within one resource.
Generating multiple resources is count or for_each, and the two get confused constantly.
When NOT to use them
- Don't use
dynamicwhen the argument takes a list. Many provider arguments that look like blocks are attributes accepting a list of objects — in which case you assign aforexpression directly and thedynamicblock is pure noise. Check the provider schema before reaching for it. - Don't use
dynamicfor a fixed, small number of cases. Two possible blocks written out are readable; adynamicblock that produces them is not. - Don't use
timestamp()oruuid()in resource arguments. They return a new value every evaluation, so every plan shows a change and no apply ever settles. Use therandomprovider, whose values are stored in state. - Don't nest conditionals more than one deep.
a ? b : c ? d : eis legal and nobody can review it. Alookupagainst a map of cases is clearer, and separate configurations are clearer still. - Don't reach for
try()as a general error suppressor. It's for genuinely optional structure, not for hiding a type mistake — it will swallow the error that would have told you what's wrong.
Core Concepts
Expression — anything producing a value. Literals, references, operators, function calls,
conditionals, for expressions, splats.
Operator — built-in arithmetic, comparison and logic. + - * / %, == != < <= > >=,
&& || !. Standard precedence; parenthesise when it matters to a reader.
Conditional expression — condition ? true_value : false_value. Both result values must be
convertible to a common type. It does not reliably short-circuit for the purposes of errors —
⚠️ verify — so an invalid expression in the branch not taken can still fail the plan.
for expression — build a new collection from an existing one. Square brackets produce a
tuple/list; braces produce an object/map. Optional if clause filters.
Grouping mode — ... in a for expression. Produces a map of lists rather than failing on
duplicate keys.
Splat expression — [*], shorthand for a for over a collection's attribute.
aws_s3_bucket.this[*].id. The legacy .*. form exists and behaves subtly differently. ⚠️ verify.
Function — a built-in transformation. Pure, deterministic within a run, evaluated at plan time. You
cannot define your own; the closest thing is a local that names an expression.
try() — return the first argument that evaluates without error. For genuinely optional structure.
can() — did that expression succeed? Returns a bool. The idiom for validation conditions.
coalesce() — first non-null, non-empty argument. For defaults. Distinct from try, which is
about errors rather than emptiness.
dynamic block — generate repeated nested blocks. A for_each and a content block inside a
resource. Produces blocks, never resources.
templatefile() — render an external template with variables. Template syntax supports ${}
interpolation and %{ } directives for conditionals and iteration.
Directive — %{if} / %{for} inside a template string. Control flow available in template
strings and templatefile, not in ordinary expressions.
Plan-time evaluation — when all of this happens. Every expression here is resolved during planning, except where it depends on an unknown value — in which case the result is unknown and propagates. See The Plan/Apply Lifecycle.
How It Works
Conditionals
locals {
# The standard form.
replication = var.environment == "prod" ? "GRS" : "LRS"
# The idiom for "set this argument only sometimes" — null means "unset".
lock_retention = var.enable_lock ? var.retention_days : null
}
Two things to know. Both result values must share a type, or Terraform converts them — so
var.x ? "3" : 5 gives you strings. And an error in the untaken branch can still surface, which
matters for the common shape var.obj != null ? var.obj.field : null: if var.obj is null, referencing
.field may fail despite the guard. The safe form is try(var.obj.field, null). ⚠️ verify current
short-circuit behaviour — this has changed across versions and it's the most consequential uncertainty on
this page.
for expressions
locals {
names = ["logs", "media", "backups"]

# List → list.
prefixed = [for n in local.names : "${var.name_prefix}-${n}"]
# ["app-logs", "app-media", "app-backups"]
# List → map. The => makes it an object.
by_name = { for n in local.names : n => "${var.name_prefix}-${n}" }
# { logs = "app-logs", media = "app-media", backups = "app-backups" }
# With a filter.
long_names = [for n in local.names : n if length(n) > 4]
# ["media", "backups"]
# Map → map, transforming both sides. This is the tags→labels workhorse.
lowered = { for k, v in var.tags : lower(k) => lower(v) }
# Two iterator variables over a list gives index and value.
indexed = { for i, n in local.names : n => i }
# { logs = 0, media = 1, backups = 2 }
}
The shape to internalise: brackets out, tuple; braces with =>, object. Everything else is
variations on the filter and the iterator variables.
Grouping mode handles duplicate keys, which otherwise error:
locals {
buckets = [
{ name = "logs-eu", region = "eu" },
{ name = "logs-us", region = "us" },
{ name = "media-eu", region = "eu" },
]
# Without ... this fails: "eu" appears twice.
by_region = { for b in local.buckets : b.region => b.name... }
# { eu = ["logs-eu", "media-eu"], us = ["logs-us"] }
}
Splat
locals {
# These two are equivalent.
ids_splat = aws_s3_bucket.many[*].id
ids_for = [for b in aws_s3_bucket.many : b.id]
}
Splat is shorthand, and worth using where it's clearly a projection of one attribute. Prefer the for
form the moment you need a filter, a transformation, or two attributes — at which point splat can't
express it anyway.
Functions — the fifteen that matter
There are around 150. These are the ones worth knowing without looking up:
| Function | Does | Use it for |
|---|---|---|
merge(m1, m2, …) |
Combines maps, later wins | Layering common tags with per-resource tags |
lookup(map, key, default) |
Value or default | Reading a map of per-environment settings |
try(a, b, …) |
First non-erroring value | Optional nested structure |
can(expr) |
Did it succeed — bool | validation conditions, with regex |
coalesce(a, b, …) |
First non-null/non-empty | Defaulting a chain of inputs |
one(list) |
The single element, or null | Reading a count = 0 or 1 resource |
toset / tolist / tomap |
Type conversion | Feeding things that demand a set |
flatten(list) |
Collapses nested lists one level | Combining per-item lists into one |
distinct(list) |
Removes duplicates, keeps order | When you want dedup without losing order |
keys / values |
Map's keys or values | Iterating or validating a map |
contains(list, v) |
Membership — bool | validation against an allowed set |
jsonencode / jsondecode |
Object ↔ JSON string | Every policy document. Never build JSON by hand |
templatefile(path, vars) |
Renders a template file | Config files, scripts, long documents |
format / join / split |
String assembly and division | Names, CSV-ish provider arguments |
cidrsubnet(cidr, bits, n) |
Derives a subnet | Network address allocation |
try versus can versus coalesce trips people, and the distinction is precise:
try(var.config.retention, 30) # error → fall back. For optional structure.
can(regex("^[a-z]+$", var.name)) # error → false. For validation conditions.
coalesce(var.explicit, var.default, "fallback") # null/empty → next. For defaults.
jsonencode, and why it's not optional
# Good: type-checked, correctly escaped, cannot be malformed.
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Deny"
Principal = "*"
Action = "s3:*"
Resource = ["${aws_s3_bucket.data.arn}/*"]
Condition = { Bool = { "aws:SecureTransport" = "false" } }
}]
})

The heredoc alternative produces the same JSON right up until an interpolated value contains a quote or a
newline. For a policy document that isn't a syntax error you'll notice — it's a permissions grant that
differs from what you meant. Treat jsonencode as a correctness requirement, not a style choice.
templatefile
# templates/bucket-notice.txt.tftpl
# Managed by Terraform. Do not edit by hand.
# Environment: ${environment}
#
# Buckets:
%{ for name in bucket_names ~}
# - ${name}
%{ endfor ~}
locals {
notice = templatefile("${path.module}/templates/bucket-notice.txt.tftpl", {
environment = var.environment
bucket_names = local.prefixed
})
}
%{ for } and %{ if } are directives, available in template strings, and the ~ strips
surrounding whitespace. Two rules: the .tftpl extension is conventional and helps editors; and
never use templatefile to produce JSON or YAML — use jsonencode or yamlencode, for the same
reason as above.
dynamic blocks — and why they're usually wrong
# The mechanism.
resource "aws_s3_bucket_lifecycle_configuration" "data" {
bucket = aws_s3_bucket.data.id

dynamic "rule" {
for_each = var.lifecycle_rules # a map or set
content {
id = rule.key
status = "Enabled"
expiration {
days = rule.value.expiration_days
}
}
}
}
dynamic "rule" generates repeated rule blocks; rule.key and rule.value are the iterator, named
after the block. It works, and here is the argument against reaching for it:
First, check whether you need it at all. If the provider argument is an attribute taking a list of
objects rather than a repeatable block, you assign a for expression directly and there's nothing
dynamic about it. Providers have been migrating arguments in this direction, so the answer changes by
version — check the schema. ⚠️ verify a concrete current example before publishing this claim.
Second, it destroys readability. A dynamic block hides the resource's actual shape behind a data
structure defined elsewhere, so a reviewer can't see what will be created without mentally executing the
expression against the variable's current value. For a resource whose whole purpose is to be reviewed,
that's a real cost.
Third, the alternatives are usually better. Two or three fixed cases: write them out. Genuinely
variable structure: consider whether the resource should be for_each'd instead, so each instance is a
separate reviewable graph node.
The honest legitimate case is a genuinely variable-length list of homogeneous nested blocks in a
published module whose consumers need it configurable — lifecycle rules, ingress rules, and not much
else. Even then, one dynamic block per resource is a reasonable ceiling.
Where the clouds diverge — normalising metadata
The expressions are Terraform's. What forces you to use them is that GCP labels won't accept what AWS and Azure tags will, which was raised in HCL & the Type System and is properly solved here. One canonical map, one transformation:
```hcl
locals {
common_tags = {
Environment = var.environment
CostCentre = var.cost_centre
ManagedBy = "Terraform"
}
}
resource "aws_s3_bucket" "data" {
bucket = "${var.name_prefix}-data"
tags = merge(local.common_tags, { Role = "data" })
}
```
The canonical map passes through unchanged. `merge` layers a per-resource tag on
top, later argument winning — this is `merge`'s single most common use.
```hcl
locals {
common_tags = {
Environment = var.environment
CostCentre = var.cost_centre
ManagedBy = "Terraform"
}
}
resource "azurerm_storage_account" "data" {
name = "${var.name_prefix}data"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
account_tier = "Standard"
account_replication_type = var.environment == "prod" ? "GRS" : "LRS"
tags = merge(local.common_tags, { Role = "data" })
}
```
Identical tag handling to AWS. The expression work Azure forces on you is in the
*name*, not the tags — no hyphens permitted, so names are concatenated rather
than joined.
```hcl
locals {
common_tags = {
Environment = var.environment
CostCentre = var.cost_centre
ManagedBy = "Terraform"
}
# Derived, not hand-maintained: lowercase both sides, hyphens to underscores.
common_labels = {
for k, v in local.common_tags :
lower(replace(k, "-", "_")) => lower(replace(v, "-", "_"))
}
}
resource "google_storage_bucket" "data" {
name = "${var.name_prefix}-data"
location = var.location
labels = merge(local.common_labels, { role = "data" })
}
```
The `for` expression is the whole point: one source of truth, transformed at the
boundary. Maintaining two hand-written maps instead is the alternative, and they
diverge within a month because both are valid `map(string)` and nothing detects
the drift.
Getting Started
Most of this needs no cloud account — terraform console is the right tool, as in
HCL & the Type System.
mkdir tf-expr-demo && cd tf-expr-demo
echo 'terraform { required_version = ">= 1.5" }' > versions.tf
terraform init
terraform console
# UNVERIFIED — confirm against a real run
> [for n in ["logs", "media"] : upper(n)]
[
"LOGS",
"MEDIA",
]
> { for n in ["logs", "media"] : n => length(n) }
{
"logs" = 4
"media" = 5
}
> [for n in ["logs", "media", "backups"] : n if length(n) > 4]
[
"media",
"backups",
]
> { for k, v in { Env = "Dev", Team = "Platform" } : lower(k) => lower(v) }
{
"env" = "dev"
"team" = "platform"
}
> merge({ a = 1, b = 2 }, { b = 99, c = 3 })
{
"a" = 1
"b" = 99
"c" = 3
}
> lookup({ dev = 1, prod = 3 }, "staging", 2)
2
> try({ a = 1 }.b, "fallback")
"fallback"
> can(regex("^[a-z]+$", "abc"))
true
> coalesce(null, "", "third")
"third"
> flatten([[1, 2], [3], []])
[1, 2, 3]
> jsonencode({ Version = "2012-10-17", Statement = [] })
"{\"Statement\":[],\"Version\":\"2012-10-17\"}"
> [for b in [{n="a"},{n="b"}] : b.n]
["a", "b"]
Four results are worth pausing on. merge let the later map win on b. lookup returned its
default for a missing key. coalesce skipped both null and the empty string — it's about emptiness,
not errors, unlike try. And jsonencode sorted the keys alphabetically, which is worth knowing because
it means the output won't match the order you wrote.
Then the grouping form, which is the one people look up every time:
# UNVERIFIED — confirm against a real run
> { for b in [{r="eu",n="x"},{r="us",n="y"},{r="eu",n="z"}] : b.r => b.n... }
{
"eu" = ["x", "z"]
"us" = ["y"]
}
Then one apply
# main.tf
terraform {
required_version = ">= 1.5"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" { region = "eu-west-1" }
variable "environment" {
type = string
default = "dev"
}
variable "tags" {
type = map(string)
default = { CostCentre = "CC-1234", Team = "Platform" }
}
locals {
name_prefix = "tf-expr-demo-${var.environment}"
common_tags = merge(var.tags, {
Environment = var.environment
ManagedBy = "Terraform"
})
# What GCP would need. Shown here so the transformation is visible in a plan.
as_labels = { for k, v in local.common_tags : lower(k) => lower(v) }
}
resource "aws_s3_bucket" "data" {
bucket = "${local.name_prefix}-0725"
tags = local.common_tags
}
output "labels_preview" {
value = local.as_labels
}
terraform plan
# UNVERIFIED — confirm against a real run
# aws_s3_bucket.data will be created
+ resource "aws_s3_bucket" "data" {
+ bucket = "tf-expr-demo-dev-0725"
+ tags = {
+ "CostCentre" = "CC-1234"
+ "Environment" = "dev"
+ "ManagedBy" = "Terraform"
+ "Team" = "Platform"
}
}
Changes to Outputs:
+ labels_preview = {
+ costcentre = "cc-1234"
+ environment = "dev"
+ managedby = "terraform"
}
Both maps computed at plan time, from one source. Note labels_preview shows the transformation working
— and also shows why ManagedBy becoming managedby is a readability cost you accept for GCP.
terraform apply
terraform destroy
In Practice
Name every non-trivial expression in a local. An expression buried in a resource argument is
unreadable and untestable; the same expression named in locals can be inspected with
terraform console and referenced twice:
locals {
# One place to read, one place to fix.
bucket_names = { for k, v in var.buckets : k => "${var.name_prefix}-${k}" }
}
Compute maps for for_each in locals, never inline. The map that determines resource addresses is
the most consequential expression in a configuration
(Meta-Arguments), and it deserves a name, a comment, and the
ability to be evaluated in isolation before you rely on it.
Use merge for tag layering, in one direction. Common tags first, specific tags second, so specific
wins. Consistency about the order matters more than which order, because a reader has to know without
checking.
Prefer try over defensive conditionals for optional structure, and coalesce for defaulting:
retention = try(var.config.retention_days, 30) # structure may be absent
region = coalesce(var.region, var.default_region) # value may be null
Never use timestamp(), uuid() or bcrypt() in a resource argument. They produce a new value
every evaluation, so the plan is never empty and the apply never converges. Where you need a stable
generated value, use the random provider — random_password, random_id — which stores the value in
state and regenerates only when its keepers change. This is the single most common cause of the
"permanent diff" problem in The Plan/Apply Lifecycle.
Treat dynamic as requiring justification. Before writing one: is the argument actually a list
attribute you could assign directly? Are there really more than three possible cases? Would for_each
on the resource be better? If it survives all three questions, add a comment saying what shapes it can
produce.
What a reviewer should look for in a diff touching this topic:
- Hand-built JSON or YAML. Ask for
jsonencode/yamlencode. This is a correctness issue, not style. - A new
dynamicblock. Justified against the three questions above? timestamp(),uuid()or any non-deterministic function in a resource argument.- A nested conditional.
a ? b : c ? d : e— suggest a map lookup. - A complex expression inline in a resource argument. Ask for a named local.
try()wrapping something that isn't optional structure — it's hiding an error that would have been informative.- A
forexpression whose output feeds resource addresses. Are the keys stable? Could a reordering or a rename change them? lookup()with two arguments. It errors on a missing key rather than defaulting; usually the three-argument form ortrywas intended.- Splat where a
forwould be clearer — or, more often, splat that's been contorted to do something it can't.
Blast radius and rollback. Expressions are inert on their own and fail at plan time, which is cheap.
The exception is the one that matters: an expression producing names or for_each keys determines
resource addresses and immutable arguments, so a change to it can propose mass replacement from a diff
that looks like a refactor. That's the case to read the plan for, and it's why the map deserves a local
and a comment.
Ecosystem
terraform console. The only sensible way to develop a non-trivial expression. Evaluate against real
state and real variables (-var-file=...) before committing. If one habit from this page survives, make
it this one.
jsonencode / yamlencode / jsondecode / yamldecode. The structured-data boundary. Encode when
handing data to a provider; decode when reading a config file into HCL. The glue is that both work over
native HCL objects, so the type system checks your work.
The random provider. random_password, random_id, random_pet, with keepers controlling when
values regenerate. The correct answer whenever you were about to reach for uuid(), and the reason it
works is that the value lives in state rather than being recomputed.
Provider policy-document data sources. aws_iam_policy_document and its equivalents build policies
with validation and clean merging, which is better than jsonencode for anything complex. The glue: a
data block, referenced as .json.
Resources & References.
templatefile and .tftpl files. External templates for anything long — cloud-init, config files,
notices. Keeps large text out of .tf files and lets editors syntax-highlight it.
tflint. Catches some expression-level mistakes validate accepts, including deprecated function
usage. Testing & Validation.
Provider-defined functions (Terraform 1.8+). Providers can contribute their own functions, which removes some of the string manipulation this page teaches — the direction of travel is fewer bespoke transformations. ⚠️ verify version. Extending & the Ecosystem.
Provider-specific note. Expressions and functions are entirely Terraform's. What varies is how much transformation each provider forces on you: GCP labels need case normalisation, Azure names need hyphen removal and are length-constrained, AWS needs the least. Canonical comparison in Providers & the Registry.
Production
Security
jsonencode over hand-built JSON is a security control. A policy document assembled by string
concatenation can be malformed by an interpolated value containing a quote — and a subtly malformed
policy can be accepted while granting something other than intended, which is worse than a failure. The
same applies to yamlencode for anything consumed by a downstream system. Secondly, expressions are
where sensitive values get accidentally transformed: applying a function to a sensitive value generally
keeps it sensitive, but constructing a string that embeds one is how a secret reaches a log —
Secrets & Sensitive Data.
Blast radius
One case, and it's significant: expressions that produce resource names or for_each keys. Because those
determine addresses and immutable arguments, a change to a for expression can propose destroying and
recreating everything it generates, from a diff that reads as a tidy-up. Everything else here fails at
plan time and touches nothing.
Scale
Expressions are evaluated before planning begins, so very large collections and deeply nested for
expressions add plan-time latency ahead of any API call — noticeable when a map is built from a data
source returning thousands of items. flatten and nested comprehensions over large inputs are the usual
culprits. Rarely the dominant cost compared with refresh
(Scale & Performance), and worth knowing when plan time
grows without the resource count changing.
Team workflow
The conventions worth agreeing: non-trivial expressions live in named locals; structured data is always
encoded rather than concatenated; dynamic blocks need justification in the pull request. None are
enforceable by tooling, so all three live in review. The one that pays back fastest is naming
expressions, because it makes them reviewable at all.
Reliability
Purity is the reliability property: the same inputs produce the same outputs, so a plan is reproducible.
The way to break it is to introduce non-determinism — timestamp(), uuid() — which produces a
configuration that can never reach a steady state and quietly trains the team to ignore non-empty plans.
That habit is the actual damage, and it costs more than the original mistake.
Interview Questions
Conceptual
What's a `for` expression, and how do the two forms differ?
An expression that builds a new collection from an existing one. Square brackets produce a tuple or list:
[for n in names : upper(n)]. Braces with => produce an object or map:
{ for n in names : n => length(n) }. Both accept an optional if clause to filter, and iterating a map
or using two iterator variables over a list gives you key-and-value or index-and-value.
What it isn't is a loop. Nothing is mutated and there's no accumulator — it produces a new value. If you
need to reduce a collection to a single value, you're looking for a function rather than a for
expression.
When would you use `try` versus `can` versus `coalesce`?
try(a, b) returns the first argument that evaluates without error — for genuinely optional structure,
like an attribute that may not exist on an object. can(expr) returns a boolean saying whether the
expression errored, which is the idiom inside validation blocks, usually wrapping regex. coalesce
returns the first argument that isn't null or empty — for defaulting a chain of possible values.
The distinction that matters: try and can are about errors, coalesce is about emptiness. A
common mistake is using try to default a value that's merely null, which works but hides real errors
alongside.
Why should you use `jsonencode` rather than a heredoc for policy documents?
Because a heredoc containing JSON with interpolations is string concatenation, and it produces invalid
JSON the moment an interpolated value contains a quote or a newline. jsonencode over a native HCL
object cannot produce malformed output, escapes values correctly, and gets type-checked.
For policy documents specifically this is a security argument rather than a style one: a malformed policy is sometimes accepted and grants something other than intended, which is worse than an error. The provider's policy-document data source is better still for anything complex, since it validates structure and merges statements cleanly.
What's the difference between `dynamic` and `for_each`?
for_each as a meta-argument creates multiple resources — separate graph nodes, separate addresses,
separate state entries. dynamic creates repeated nested blocks inside one resource — one graph node,
one address.
They get confused because both take a for_each argument. The test is what you need multiples of: three
buckets is for_each on the resource; one bucket with three lifecycle rules is dynamic "rule". And
where both would work, for_each on the resource is usually better, because each instance becomes
independently reviewable and independently addressable.
Why are `timestamp()` and `uuid()` dangerous in a resource argument?
They return a new value on every evaluation, so every plan shows a difference and no apply ever produces a steady state. If the attribute is immutable, that's a resource replaced on every apply; if not, it's a perpetual in-place update.
The real damage is cultural: a configuration that always shows changes trains the team to stop reading
plans, which costs far more than the original mistake. The correct tool is the random provider —
random_password, random_id — which generates once, stores the value in state, and regenerates only
when its keepers change.
Technical depth
Does a conditional expression short-circuit?
Not reliably, for the purposes of errors — which is the practically important part. The common shape
var.obj != null ? var.obj.field : null looks guarded and can still fail when var.obj is null, because
the untaken branch may still be evaluated or type-checked.
The safe form is try(var.obj.field, null), which handles the error rather than trying to avoid it. This
behaviour has varied across Terraform versions, so it's worth testing on the version you're running
rather than trusting either an old answer or this one.
What is grouping mode in a `for` expression?
The ... suffix, which collects values into lists per key instead of erroring on duplicates:
{ for b in buckets : b.region => b.name... } gives { eu = ["x","z"], us = ["y"] }.
Without it, a duplicate key is an error — which is correct behaviour, since silently keeping one of two
values would be worse. Grouping mode is how you express a genuine one-to-many, and it's the piece of
for expression syntax people look up every single time.
What's the difference between splat and a `for` expression?
Splat is shorthand for projecting one attribute across a collection: aws_s3_bucket.this[*].id is
[for b in aws_s3_bucket.this : b.id]. It's more readable for exactly that case and can't do anything
else — no filtering, no transformation, no combining two attributes.
There's also a legacy .*. form with subtly different behaviour around single values and null, which is
worth avoiding in new code. And splat over a for_each'd resource gives you values without keys, so if
you need the keys, use a for expression over the map.
When is a `dynamic` block genuinely the right answer?
When a resource takes a repeatable nested block — not a list attribute — and the number of them is genuinely variable at configuration time, typically in a published module whose consumers configure it. Lifecycle rules and firewall ingress rules are the honest examples.
Three checks first. Is the argument actually an attribute accepting a list of objects, in which case you
assign a for expression and need no dynamic at all — providers have been migrating in that direction,
so it varies by version. Are there really more than two or three cases, since fixed cases written out are
more readable? And would for_each on the resource itself be better, giving separately addressable
instances?
The cost is that a reviewer can no longer see the resource's shape without mentally evaluating an expression against a variable, which for a resource whose purpose is to be reviewed is a genuine problem.
Are Terraform functions pure? Can you write your own?
Pure, in that they're deterministic and side-effect-free within a run — which is what makes plans
reproducible. file() and templatefile() read from disk, the closest thing to an exception, and still
deterministic for a given working tree. timestamp() and uuid() are the genuine violations, and that's
exactly why they cause perpetual diffs.
You cannot define your own functions. The available substitutes are a named local for a reused
expression, a module for reused structure, and — since Terraform 1.8 — provider-defined functions, which
let a provider contribute functions but doesn't let you write them in HCL.
How does this differ across AWS, Azure and GCP?
Expressions and functions are entirely Terraform's — same syntax, same behaviour, same evaluation timing. What differs is how much transformation each provider forces you to write.
GCP needs the most for metadata: labels require lowercase keys and values with a restricted charset,
so a canonical tag map has to be transformed with a for expression rather than passed through. Doing it
by transformation rather than by maintaining two maps is the maintainable choice, because two hand-written
maps diverge and nothing detects it.
Azure needs the most for names: storage account names are 3–24 lowercase alphanumeric characters with
no hyphens, so names are concatenated rather than joined, often with replace and lower, and
frequently substr to stay within the limit.
AWS needs the least — tags pass through and bucket names permit hyphens — and it uniquely has
default_tags on the provider, which removes most tag-merging expressions altogether.
Canonical comparison in Providers & the Registry.
Scenario and design
Every plan shows a change to one resource and applying never fixes it. The argument is built by an expression. Diagnose.
First suspect a non-deterministic function in the expression — timestamp(), uuid(), or something
derived from them. Those produce a new value each evaluation, so the diff is structural and permanent.
If the expression is deterministic, the cause is likely provider normalisation: what you compute isn't
what the provider stores and returns. Sorted JSON keys, lowercased values, reordered lists, added
defaults — so the refresh reads something that never equals your expression's output. TF_LOG=DEBUG
shows what the API actually returned, which settles it.
Fixes in order of preference: remove the non-determinism, using the random provider if a generated
value is genuinely needed; write the value in the provider's canonical form, often via a policy-document
data source rather than jsonencode; and only as a last resort ignore_changes on that attribute, which
is a concession rather than a fix
(Drift & Reconciliation).
Design the expression that turns a list of environment names into per-environment settings.
I'd push back on the premise first: a list of names plus derived settings is usually better expressed as
a map(object(...)) input, so the settings are data rather than computed from a name. That removes the
expression entirely and makes each environment's configuration explicit and reviewable.
Where derivation is genuinely right — uniform settings that only vary by a prefix — a for expression
into a map keyed by name, held in a named local:
{ for e in var.environments : e => { name = "${var.prefix}-${e}", tags = merge(local.common_tags, { Environment = e }) } }.
Two constraints worth stating: the keys must be stable, because if this map feeds for_each the keys
become resource addresses and renaming one destroys infrastructure; and it needs validation on the
input names' charset for the same reason. I'd also avoid nesting a second for inside it — at that point
the data should be data.
A PR adds a `dynamic` block generating security group rules from a variable. How do you review it?
Three questions before the code. Is the argument actually a list attribute rather than a repeatable
block, in which case a direct for assignment is simpler? How many rules realistically — if it's two or
three fixed ones, written-out blocks review far better? And would for_each on a separate rule resource
be better, giving each rule its own address so a plan shows exactly which rule changed?
That last point is the strongest one for security groups specifically: with a dynamic block, changing
one rule shows as a diff on the whole resource, whereas separate rule resources show one changed node.
For something security-relevant, that reviewability difference matters.
If it survives, I'd want the variable strongly typed as a map(object(...)) rather than any,
validation on the shape, and a comment stating what it can produce. And I'd check whether the change
proposes replacing the security group rather than updating it, since that has a brief connectivity
implication.
You need to generate a config file for an application from Terraform. How?
templatefile with an external .tftpl file, passing a map of values — which keeps the template out of
the .tf file, lets an editor highlight it, and puts the interpolations where they're readable.
The important exception: if the config file is JSON or YAML, don't template it. Build a native HCL object
and jsonencode or yamlencode it, for the same reason as policy documents — a templated structured
document can be malformed by its inputs, and the failure may be silent.
Then the real question, which is what consumes the output. Writing it to disk with local_file produces
a file whose existence Terraform tracks but whose consumption it can't, and pushing it onto a machine
with a provisioner is the anti-pattern from
Why IaC Exists. Better destinations: a parameter store or
secret manager entry the application reads at start-up, an object in a bucket, or bake it into the
machine image at build time. Terraform should render the value and put it somewhere durable, not deliver
it imperatively.
Commands & Gotchas
terraform console # develop every non-trivial expression here first
terraform console -var-file=envs/dev.tfvars # ...with real inputs loaded
> [for n in ["a","b"] : upper(n)] # list comprehension
> { for k, v in m : lower(k) => v } # map comprehension
> { for x in xs : x.key => x.val... } # grouping mode, for duplicate keys
> aws_s3_bucket.this[*].id # splat, against real state
> try(var.obj.field, "default") # optional structure
> can(regex("^[a-z]+$", var.name)) # validation idiom
> jsonencode({ a = 1 }) # check the encoding before committing to it
terraform fmt -recursive # canonical formatting
| Behaviour | Why it matters |
|---|---|
[for …] gives a tuple; {for … => …} gives an object |
Brackets out, braces with => out |
Duplicate keys in a for are an error unless you add ... |
Grouping mode gives a map of lists |
| Conditionals don't reliably short-circuit for errors | var.x != null ? var.x.f : null can still fail. Use try |
try is about errors; coalesce is about null/empty |
Using try for defaulting hides real errors |
lookup(map, key) with two arguments errors on a missing key |
The three-argument form defaults. Usually what was meant |
A set has no order, so for over one has no order either |
Don't derive anything order-dependent from a set |
jsonencode sorts object keys |
So its output won't match the order you wrote |
timestamp() and uuid() produce a new value every evaluation |
Perpetual diff, never converges. Use the random provider |
| Functions are pure and evaluated at plan time | Which is what makes plans reproducible |
| You cannot define your own functions | Named locals, modules, or provider-defined functions (1.8+) |
dynamic produces nested blocks, not resources |
Multiples of a resource is for_each |
| Many block-looking arguments are list attributes | Assign a for expression directly; no dynamic needed |
An expression feeding names or for_each keys sets addresses |
A "refactor" to it can propose mass replacement. Read the plan |
← Back to The Language · Next: The Plan/Apply Lifecycle →
⚠️ Verification checklist (delete before publishing)
terraform console output — the page's central demonstration
- Every expression in the console block, with exact rendering: list output formatting (trailing
commas, indentation), map output formatting (no
=alignment? quoted keys?), and whether single-line results print as shown. -
jsonencodekey ordering. The page claims it sorts keys alphabetically and repeats that in the gotchas table and an interview answer. Verify. -
coalesce(null, "", "third")— confirm it skips the empty string as well as null. The page makes this the distinguishing feature versustry. Ifcoalesceonly skips null, three places need correcting. -
try({a=1}.b, "fallback")— confirm that syntax is valid in console and returns the fallback. - The grouping-mode example's exact output shape.
-
flatten([[1,2],[3],[]])→[1,2,3]. -
lookupwith a default for a missing key, and separately confirm two-argumentlookuperrors rather than returning null — asserted in the reviewer list and the gotchas table.
Behavioural claims, several carrying interview answers
- Conditional short-circuiting. Flagged inline as the most consequential uncertainty on the page. It appears in Core Concepts, How It Works, an interview answer and the gotchas table. Test it on a current version and reconcile all four.
- The legacy
.*.splat form's differing behaviour — asserted vaguely; either get specific or cut it. - That applying a function to a sensitive value preserves sensitivity.
- That
%{ }directives work in ordinary template strings as well as intemplatefile. - That the
~whitespace-stripping marker works as shown in the.tftplexample, and that the template renders as intended. -
.tftplbeing the conventional extension. - The claim that many block-looking provider arguments are actually list attributes. Flagged
inline — this needs a concrete current example naming a specific provider argument, or the claim
should be softened. It's used to argue against
dynamicin three places. -
randomproviderkeepersbehaviour. - Provider-defined functions version (1.8) — same item as the providers page.
Provider-specific claims
-
aws_s3_bucket_lifecycle_configurationand itsruleblock being a repeatable block rather than a list attribute — thedynamicexample depends on it. - Azure storage account naming, and whether
substris genuinely needed in practice. Sixth page asserting the 3–24 rule; verify once. - GCP label charset — sixth page asserting it.
Versions
-
~> 5.0aws — eleventh and final Stage 1 page. Run the sweep across all ofdocs/now.
Structure
- One
<Tabs>block, showing metadata handling. It's the fullest version of a point introduced in01-hcl-and-types.md— check the two pages don't read as redundant, and that this one is clearly the canonical treatment. - Length: this page is reference-heavy. Confirm the function table hasn't tipped it over, and that the page reads as a topic rather than a catalogue.
- All relative links resolve. Stage 1 is now complete, so intra-stage links should all be live.