Background

Expressions and Functions

36 min read

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 dynamic when the argument takes a list. Many provider arguments that look like blocks are attributes accepting a list of objects — in which case you assign a for expression directly and the dynamic block is pure noise. Check the provider schema before reaching for it.
  • Don't use dynamic for a fixed, small number of cases. Two possible blocks written out are readable; a dynamic block that produces them is not.
  • Don't use timestamp() or uuid() in resource arguments. They return a new value every evaluation, so every plan shows a change and no apply ever settles. Use the random provider, whose values are stored in state.
  • Don't nest conditionals more than one deep. a ? b : c ? d : e is legal and nobody can review it. A lookup against 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

Expressionanything producing a value. Literals, references, operators, function calls, conditionals, for expressions, splats.

Operatorbuilt-in arithmetic, comparison and logic. + - * / %, == != < <= > >=, && || !. Standard precedence; parenthesise when it matters to a reader.

Conditional expressioncondition ? 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 expressionbuild 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.

Functiona 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 blockgenerate 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 evaluationwhen 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"]

![Diagram of a for expression transforming an input collection into either a list or a map, with an optional filter stage](/articles/assets/terraform/docs/01-language/assets/05-for-expression.webp)

  # 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" } }
  }]
})

Comparison of building structured data by string concatenation against encoding a structured object

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

![Comparison of one resource containing repeated nested blocks against three separately addressed resources](/articles/assets/terraform/docs/01-language/assets/05-dynamic-vs-foreach.webp)

  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 dynamic block. 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 for expression 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 or try was intended.
  • Splat where a for would 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.
  • jsonencode key 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 versus try. If coalesce only 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].
  • lookup with a default for a missing key, and separately confirm two-argument lookup errors 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 in templatefile.
  • That the ~ whitespace-stripping marker works as shown in the .tftpl example, and that the template renders as intended.
  • .tftpl being 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 dynamic in three places.
  • random provider keepers behaviour.
  • Provider-defined functions version (1.8) — same item as the providers page.

Provider-specific claims

  • aws_s3_bucket_lifecycle_configuration and its rule block being a repeatable block rather than a list attribute — the dynamic example depends on it.
  • Azure storage account naming, and whether substr is genuinely needed in practice. Sixth page asserting the 3–24 rule; verify once.
  • GCP label charset — sixth page asserting it.

Versions

  • ~> 5.0 aws — eleventh and final Stage 1 page. Run the sweep across all of docs/ now.

Structure

  • One <Tabs> block, showing metadata handling. It's the fullest version of a point introduced in 01-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.