Chapter 6: Iteration and Conditionals
Topic 40

Splat Expressions

Expressions

A splat expression pulls one attribute out of every element of a list in one short form: google_storage_bucket.raw[*].name is the names of all the buckets, equivalent to a for expression but terser. The modern [*] operator does two things people misread as quirks — it wraps a single non-null value into a one-element list and turns a null into an empty list — and the older .* form differs from it in exactly one place: how a trailing index attaches.

Get that last point right and the rest of the topic is easy. Most explanations attribute the wrapping behavior to the legacy form and call it a quirk to avoid; that is backwards. The wrapping is an intentional, useful property of the modern [*] operator. The genuine difference between [*] and .* is the trailing-index rule, and confusing the two will silently return the wrong value.

The Full Splat

The full splat list[*].attr projects attr from every element of a list. On a counted or for_each resource — which Terraform treats as a list of instances — google_storage_bucket.raw[*].name gives every bucket's name without writing a for expression. It is the concise, idiomatic way to read one attribute across an entire fleet of instances, and it is what you reach for first when an output needs "all the ids" or "all the names."

Every bucket's name, in one expression
output "bucket_names" {
  value = google_storage_bucket.raw[*].name   # list of all instance names
}

Single-Value Wrapping

Applied to a single, non-list value, [*] wraps it into a one-element list; applied to null, it yields an empty list []. These are intrinsic behaviors of the [*] operator itself, not artifacts of any legacy form. They are useful precisely because they normalize an optional-or-single attribute into a list you can always iterate: var.maybe_one[*] is [var.maybe_one] when it is set and [] when it is null, so downstream code can treat the optional value as a list of zero or one without a conditional.

The Legacy Form

The older attribute-only splat list.*.attr predates [*] and exists mainly in older code. It works on lists of objects and projects the attribute much as the full splat does, but it does not perform the single-value and null wrapping that [*] does — feed it a single non-list value and you do not get the one-element list [*] would give you. In new code there is no reason to choose it; it survives only because old configurations used it before [*] existed.

The Real Difference

Here is the genuine [*]-versus-.* distinction, and it is about a trailing index, not the wrapping. With the full splat, list[*].attr[0] applies the [0] inside the projection — it takes the first element of each element's attr, returning a list with one value per instance. With the legacy form, a trailing index applies to the whole result of the splat — list.*.attr[0] projects every attr first, then indexes [0] into that result, returning the first instance's attribute only. Same characters, different answers.

Where the trailing [0] attaches
# [*]: the [0] is applied inside the projection (first of each instance's attr)
google_compute_instance.vm[*].network_interface[0].network_ip

# .*: a trailing index applies to the whole splat result (first instance only)
google_compute_instance.vm.*.name[0]   # == the first instance's name, not a per-instance list

This is the trap. list[*].attr[0] and list.*.attr[0] look interchangeable and silently return different shapes — a per-instance list from one, a single value from the other. When you touch code that uses .*, work out where a trailing index lands before assuming it matches the [*] reading.

Splat vs for Expression

[*] is the concise choice for a straight attribute projection — read one attribute off every instance and you are done. Switch to a for expression the moment you need to do more than project: transform a value, filter with an if, or build a keyed map. Splat only projects; it has no filter clause and no way to reshape the output into a map. The line is clean — projection is splat, anything else is for.

Common Mistakes
  • Believing the single-value-to-list wrapping is a stale .* behavior to avoid — it is a defined, useful property of the modern [*] operator for normalizing optional attributes.
  • Assuming list[*].attr[0] and list.*.attr[0] mean the same thing — with [*] the index is per-element and inner, with .* it indexes the whole splat result, which silently returns different values.
  • Reaching for a splat where transformation is needed — splat only projects an attribute, so any reshaping, filtering, or keying requires a for expression instead.
  • Splatting a resource whose count or for_each made it empty and not handling the resulting empty list downstream, so a consumer expecting at least one element breaks.
  • Using .* in new code out of habit — prefer [*], which is the current operator with the well-defined null and single-value semantics.
  • Outputting a splat across a conditionally-created resource and assuming [0] is safe to index afterward, when the list may be empty and the index fails.
Best Practices
  • Use [*] for plain attribute projection across counted or for_each instances, and reach for a for expression the moment you need to transform or filter.
  • Rely on [*]'s single-value-to-list and null-to-empty-list wrapping to normalize an optional attribute, knowing it is intentional behavior.
  • Prefer the modern [*] over the legacy .* in new code, and understand the trailing-index difference before touching code that uses .*.
  • Handle the empty-list case when splatting across a conditionally-created (count = 0) resource so downstream consumers do not assume an element exists.
  • Guard a possibly-empty splat with one(...) or a length check before indexing, rather than hardcoding [0].
Comparable approaches Pulumi .map(x => x.attr) over a real array jq .[].attr AWS CLI JMESPath wildcard projections gcloud no equivalent, splat is an HCL language feature

Knowledge Check

What does the modern [*] operator do to a single non-null value and to a null?

  • Wraps the single value into a one-element list, and turns null into an empty list — both intentional behaviors
  • Errors on a single value, because [*] strictly requires an actual list input
  • Returns the single value completely unchanged, and likewise passes a null straight through the projection as null
  • Wraps a single value into a list but leaves a null untouched as null

What is the real difference between [*] and the legacy .* form?

  • How a trailing index attaches — [*] applies it inside the projection, .* applies it to the whole splat result
  • The single-value-to-list wrapping on a lone value, a convenience behavior that only the legacy .* form actually performs
  • .* projects over maps while [*] projects only over lists of objects
  • [*] filters out null elements while the legacy .* form keeps them

When does a splat suffice and when do you need a for expression instead?

  • Splat suffices for a plain attribute projection; a for expression is needed to transform, filter, or build a map
  • Splat handles everything a for expression does, including filtering and map building, only in a faster and terser form
  • A for expression is strictly for lists, while a splat is strictly for maps
  • Splat is required whenever a resource is expanded with for_each

What does splatting an attribute across a counted resource with count = 0 return?

  • An empty list, which downstream consumers must handle before assuming an element exists
  • Null, which then silently propagates through any reference downstream
  • A plan-time error, because a zero-count resource cannot be splatted at all
  • A one-element list holding a synthesized default placeholder standing in for the absent instance

You got correct