One Attribute, Every Instance: What [*] Expands To
You have three web servers behind a load balancer and an argument somewhere — a security group's source_security_group_id, an IAM policy's Resource list, an output for the network team — that needs all their identifiers, not just one. Writing the reference three times by index works until a fourth server appears. The splat expression solves it in one token:
resource "aws_instance" "web" {
count = 3
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.micro"
}
output "private_ips" {
# ["10.0.1.10", "10.0.1.11", "10.0.1.12"]
value = aws_instance.web[*].private_ip
}
output "first_private_ip" {
# "10.0.1.10" — plain attribute access, no list involved
value = aws_instance.web[0].private_ip
}
The syntax rule matters: [*] goes between the resource and the attribute. aws_instance.web[*].private_ip means "the private_ip of every element of web." The result is always a list, even when the source is a set or tuple, and it is empty when the collection is empty — no nil, no error, an empty list you can pass straight to arguments that accept zero elements.
The contrast with indexed access is the whole mental model:
| Expression | Reads | Result type |
|---|---|---|
aws_instance.web[0].private_ip |
one attribute of one element | string |
aws_instance.web[*].private_ip |
one attribute of every element | list of strings |
aws_instance.web[0] |
one whole element | object |
aws_instance.web[*] |
every whole element | list of objects |
That last row matters more than it looks — you will reuse it in the named-splat chapter.
Terraform still supports the legacy spelling aws_instance.web.*.private_ip, where the splat sits directly after the resource. It means exactly the same thing, and you will read it in older repositories and Stack Overflow answers, but [*] is the form to write: it is unambiguous and works in the same position regardless of what precedes it.
Splat is also the idiomatic way to fan out into other resources without count.index. Compare:
# index coupling — breaks if the counts ever differ
resource "aws_ebs_volume" "data" {
count = 3
availability_zone = aws_instance.web[count.index].availability_zone
size = 20
}
# list-shaped inputs take the splat directly
resource "aws_cloudwatch_log_group" "web" {
name = "web/${join("-", aws_instance.web[*].id)}"
retention_in_days = 30
}
One dependency subtlety: an indexed reference creates an edge from your resource to a single instance, while a splat creates an edge to the whole collection. If any element is not yet applied, the attribute list is unknown at plan and shows as (known after apply). That is correct behavior — Terraform cannot list IPs it has not created — but it means splatted values rarely appear in plan-time assertions. You will meet that exact constraint in Plan Assertions and Expectations, where plan-only tests must assert on attributes whose values are known during planning.
Splat pairs with the phase you just left, too. dynamic Blocks for Repeatable Nesting generates repeated nested blocks from a collection; splat is how you read attributes back out of repeated objects once those resources exist. Generation on the way in, extraction on the way out.
Two edge cases close the basics. A resource declared with count = 0 splats to an empty list — the natural way to write "zero or more" wiring without conditionals. And splat works on module instances the moment a module is called with for_each or count, with the same map caveat as resources:
# module declared with for_each — map-shaped, so values() first
output "site_bucket_arns" {
value = values(module.sites)[*].bucket_arn
}
# module declared with count — list-shaped, splat directly
output "replica_bucket_arns" {
value = module.replica[*].bucket_arn
}
The behavior is identical because a module instance exposes outputs exactly the way a resource exposes attributes. That symmetry is the sign you have the model right: splat is a property of collections, not of resources.
Named Splats: Bind the Expression Once, Read Every Attribute
A named splat assigns the result of a splat expression to a local, so one long address gets expanded once and many attributes get read from it. The convention is an uppercase identifier:
locals {
Ingress = aws_security_group.app.ingress[*]
}
output "open_ports" {
value = [for rule in local.Ingress : rule.from_port]
}
output "rule_summary" {
value = [for rule in local.Ingress :
"${rule.from_port}-${rule.to_port}: ${rule.description}"
]
}
local.Ingress is a list of objects — every ingress block the security group declares — and each element exposes the full nested attribute set (from_port, to_port, cidr_blocks, description, …). Without the named splat, every output would repeat aws_security_group.app.ingress[*] before its own attribute; with it, the address appears once and the loops stay readable. This is the same trade-off as binding any expensive expression, but splats make it visible because the address is usually long and the attribute list usually grows.
The named splat's real payoff is filter-then-extract. Raw splat gives you every element; a for expression over it narrows to what you care about before pulling attributes:
locals {
InternalRules = [
for rule in aws_security_group.app.ingress[*] : rule
if contains(rule.cidr_blocks, "10.0.0.0/8")
]
}
output "internal_ports" {
value = [for rule in local.InternalRules : rule.to_port]
}
The if clause discards rules that do not match, and only then does attribute extraction happen. Trying to express that with splat alone is impossible — splat has no filtering — which is why the two features are listed together in any serious Terraform discussion. The for expression itself, including the map-producing { ... } form and the :... => ... grouping directive, is treated in depth in for Expressions and Comprehensions.
Two practical notes. First, a named splat captures a snapshot of a specific attribute list; if the security group's rules are managed by separate aws_security_group_rule resources instead of inline blocks, there is no ingress[*] to read — the collection only exists for inline blocks. That is one of the trade-offs between the inline and standalone rule styles, and it is the kind of thing code reviewers notice. Second, length(local.Ingress) is the count of declared blocks, which is a clean way to assert a minimum number of rules in a test, and splatted lists work directly in for_each-adjacent helpers like toset() and setunion() when the downstream argument needs a set.
Named splats are pure convenience with no behavior change — local.Ingress and aws_security_group.app.ingress[*] always hold the same list — so adopting them in a codebase is a one-line-at-a-time refactor with zero plan diff. That property makes them a safe habit rather than a style debate.
One composition worth stealing turns a named splat back into input for another repeated resource — extraction feeding generation:
resource "aws_security_group_rule" "per_ingress_port" {
for_each = toset([
for rule in aws_security_group.app.ingress[*] : tostring(rule.from_port)
])
type = "ingress"
from_port = tonumber(each.value)
to_port = tonumber(each.value)
protocol = "tcp"
security_group_id = aws_security_group.app.id
source_security_group_id = aws_security_group.web.id
}
The inline rules declare intent; the standalone rules consume it one port at a time. Whether that duplication is desirable depends on the team's stance on inline versus standalone rules — the mechanism, though, is the standard splat-plus-for-plus-toset pipeline, and every stage of it was introduced somewhere in this phase. Note the small tostring/tonumber dance as well: set elements are strings, so numeric attributes round-trip through conversion — a detail that saves ten minutes of staring at Invalid number errors the first time you write the pattern.
Where Splat Misleads: for_each Maps, Unknowns, and Sensitive Values
Three failure modes separate people who have written splats in anger from people who have only read about them.
First: splat does not apply to a for_each resource. A for_each resource is a map of instances keyed by string, and splat operates on lists, tuples, and sets. This is an error:
# ERROR: aws_instance.web is a map when declared with for_each
output "ips" {
value = aws_instance.web[*].private_ip
}
The bridge is values(), which converts the instance map into a list of objects:
output "ips" {
value = values(aws_instance.web)[*].private_ip
}
Understand the identity consequence before you reach for it reflexively: values() returns objects in key order (Terraform sorts map keys lexicographically), so the resulting list is stable — but its order is the map's key order, not any order a human chose. When position must mean something, key it explicitly with a for expression instead. And note the common interview trap in this area: aws_instance.web["eu-west"].private_ip is plain attribute access with no splat involved, returning a single string.
Second: splatted computed attributes are unknown at plan time. If any element's attribute is not known until apply, the entire list is (known after apply):
resource "aws_security_group" "api" {
# valid config, but the value cannot be resolved during plan
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
security_groups = [aws_security_group.web[0].id]
}
}
Scale that to aws_instance.web[*].id and the whole list is unknown until the instances exist. Two consequences: plan-time tests cannot assert on it without a mock or override — see Mocking Providers in Tests, and any for_each or toset() consuming that list inherits the known-at-plan error from the previous topic. Extracting from configuration-declared nested blocks (like the security group's own ingress rules) avoids this entirely, because those values were written by you, not computed by a cloud API — that is exactly why the named-splat chapter read ingress[*] rather than fetched rule IDs.
Third: sensitivity propagates through the whole splat. If one element's attribute is marked sensitive, the resulting list is sensitive as a unit — sensitive = true on every derived value, redacted in plan output. There is no per-element partial redaction. When you mark a single resource attribute sensitive and then splat it into an output, the entire output goes redacted, which surprises people debugging an output that "used to print." The marking rules and what redaction does and does not protect are the subject of Sensitive Values and Redaction.
The interview-ready summary: splat extracts one attribute from every element of a list-shaped collection and always returns a list; maps need values() first; computed attributes make the list unknown; and filtering belongs to for expressions, not to splat. Know those four sentences and the follow-up questions answer themselves.
A compact reference for the whole topic:
| Situation | Expression |
|---|---|
| count resource, one attribute | aws_instance.web[*].private_ip |
| for_each resource, one attribute | values(aws_instance.web)[*].private_ip |
| whole elements as a list | aws_instance.web[*] |
| filter before extracting | [for r in coll[*] : r.x if condition] |
| element count | length(aws_instance.web[*].id) |
Expect to produce that table from memory under mild pressure — splat questions in interviews usually arrive as small puzzles ("give me the IDs of every instance tagged prod") rather than syntax checks, and the answer is always one line from this list.
Quiz
1. What does `aws_instance.web[*].private_ip` return when web is declared with `count = 3`?
2. A resource is declared with for_each over a map. Which expression extracts the private_ip of every instance?
3. You want only the ingress rules whose cidr_blocks include 10.0.0.0/8, then their to_port values. What is the correct shape?
4. An output splats an attribute that AWS only returns after creating the resource. What does plan show for that output?
Flashcards
Question
What does a splat expression return, and where does the [*] go?
Click to reveal answer
Answer
It returns a list containing one attribute from every element of a list-, tuple-, or set-shaped collection. The [*] sits between the collection and the attribute: aws_instance.web[*].private_ip. The legacy form web.*.private_ip means the same thing but is no longer written in new code.
Question
What is a named splat and when is one worth using?
Click to reveal answer
Answer
A named splat binds a splat expression to a local, conventionally uppercase: local.Ingress = aws_security_group.app.ingress[*]. It is worth it when several expressions need attributes from the same collection — the long address appears once and loops read cleanly. It changes no behavior; the local holds the same list.
Question
Why can you not splat a for_each resource directly, and what is the fix?
Click to reveal answer
Answer
A for_each resource is a map of instances keyed by string, and splat only operates on lists, tuples, and sets. The fix is values(resource)[*].attr — values() converts the map to a list in lexicographic key order, which is stable but not a human-chosen order.
Question
When is a splatted value unknown at plan time, and what breaks downstream?
Click to reveal answer
Answer
When any element's attribute is computed by a cloud API rather than declared in configuration, the whole list renders (known after apply). Downstream, plan-time assertions on it fail and any for_each or toset() consuming it hits the known-at-plan error.
Question
How do splat and for expressions divide the work of filtering and extracting?
Click to reveal answer
Answer
Splat extracts one attribute from every element but cannot filter. A for expression iterates the collection, applies an if clause to keep only matching elements, and extracts attributes — so precise selection is always [for x in coll[*] : x.attr if condition].
Revision Notes
Key Takeaways
- 1. [*] between collection and attribute returns a list of that attribute from every element — always a list, empty when the collection is empty
- 2. A for_each resource is a map, so splat needs values() first; the resulting order is lexicographic key order
- 3. Named splats (local.Ingress = sg.ingress[*]) bind a long address once; they are pure readability with zero plan diff
- 4. Computed attributes make the entire splat unknown at plan, which breaks plan-time assertions and known-at-plan consumers
- 5. Filtering belongs to for expressions with an if clause — splat alone extracts, never selects
Interview Tips
- • "What is the difference between res[0].attr and res[*].attr?" — string versus list, one element versus every element; add that the result of [*] is always a list even for sets and tuples
- • "How do you splat a for_each resource?" — values(res)[*].attr, because the instance container is a map and splat requires list-like input; mentioning the key-sorted order shows you have run it
- • "Why is my output (known after apply)?" — one element's attribute is API-computed, so the whole splatted list is unknown at plan; the fix for tests is mocks or assert only on config-declared values
- • "Can you filter what a splat returns?" — not with splat; use a for expression with an if clause over the splatted list, and say the words 'filter-then-extract'
Cheat Sheet
Splat and Attribute Access
Core forms:
| Expression | Result |
|---|---|
res[0].attr |
one string (plain access) |
res[*].attr |
list of one attribute per element |
res[*] |
list of whole objects |
values(res)[*].attr |
splat over a for_each instance map |
value = aws_instance.web[*].private_ip # count resource
value = values(aws_instance.web)[*].private_ip # for_each resource
local { Ingress = aws_security_group.app.ingress[*] }
[for r in local.Ingress : r.to_port if contains(r.cidr_blocks, "10.0.0.0/8")]
Rules:
- Result is always a list; empty collection → empty list
- Splats do not apply to maps —
values()first - One unknown element → whole list
(known after apply)at plan - Sensitivity propagates to the entire splatted list
- Legacy
res.*.attrworks; write[*]in new code
Filtering: never in splat itself — combine with a for expression's if clause.