Splat 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."
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.
# [*]: 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.
- 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]andlist.*.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
forexpression instead. - Splatting a resource whose
countorfor_eachmade 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.
- Use
[*]for plain attribute projection across counted orfor_eachinstances, and reach for aforexpression 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].
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
forexpression is needed to transform, filter, or build a map - Splat handles everything a
forexpression does, including filtering and map building, only in a faster and terser form - A
forexpression 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