Traversals: Reading Terraform's One Namespace Left to Right
Terraform keeps every addressable object in one flat namespace, and a reference expression walks into it with dots. The five prefixes you will use every day:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id # data source attribute
subnet_id = aws_subnet.private[0].id # managed resource, indexed
iam_instance_profile = var.instance_profile # input variable
tags = local.common_tags # local value
vpc_security_group_ids = [aws_security_group.web.id] # resource → attribute, wrapped in a list
}
The traversal reads left to right, and the first segment names the scope: var, local, data, module, each, count, terraform are reserved prefixes; anything else starts with a resource type (aws_instance, aws_subnet). The second segment is the object's local name, and together they form the address you met in state — aws_instance.web — with attribute accesses (.id, .private_ip), indexes ([0], ["blue"]), and more segments continuing to the right. Every one of these is an expression, which leads to the assignment rule that HCL enforces without exception: name = expression — an equals sign between an identifier and exactly one expression. You cannot write ami = "x" "y", and there is no comma-separated multi-assignment; repeated attributes in one body are a parse error, as you saw in Block Structure and Nesting.
The error messages from bad traversals are a vocabulary worth learning, because they localize your bug in one read. "Reference to undeclared resource" means the type+name does not exist anywhere in the directory — usually a typo or a resource you deleted. "Unsupported attribute" means the object exists but has no attribute of that name — usually a provider version change or a wrong guess about the schema, fixed by reading terraform state show <addr> for the real attribute list. "Invalid index" means the key is out of range or missing from the collection. And "A reference to a resource type must be followed by at least one attribute access" fires when you write aws_instance.web alone where a scalar is expected — a whole resource object is not a string, so you must descend to an attribute (.id).
Two traversal forms deserve separate mention because they show up constantly in real configurations. The splat expression compresses "map every element to an attribute": aws_subnet.private[*].id is the shorthand for projecting the id of every element, producing a list of IDs that feeds directly into arguments like vpc_security_group_ids or a join(",", ...) — you saw it in the last topic and will keep seeing it. And index expressions accept expressions, not just literals: var.subnets[each.value], local.rules["ingress"], and data.aws_subnets.chosen.ids[0] are all ordinary traversals with a computed key, which means dictionaries (maps and objects) double as lookup tables in configurations — a locals map of per-environment settings indexed by terraform.workspace is a pattern you will meet again in the variables topic.
Two practical rules complete the picture. References may appear anywhere an expression may: attribute values, variable defaults are the one exception (defaults cannot reference anything), outputs, count and for_each expressions, even inside string templates. And the reference does not "fetch a value" the way a function call might — it establishes a dependency. When Terraform sees subnet_id = aws_subnet.private[0].id it records: this resource cannot be planned before that subnet's ID is known. Every dependency edge in the graph starts as a dot in a reference like this one, which you will manipulate deliberately in Implicit and Explicit Dependencies.
Meta-arguments: Keys Terraform Interprets Instead of the Provider
Open a resource block's schema documentation and you will never find the word count. That is because some keys in a block body are interpreted by Terraform itself, before any provider is consulted — meta-arguments. Recognizing them by name is the difference between reading configuration and misreading it:
| Meta-argument | Allowed in | What Terraform does with it |
|---|---|---|
count |
resource, data, module | makes N instances, addressed [0..N-1] |
for_each |
resource, data, module | one instance per map/set element, addressed by key |
depends_on |
resource, data, module | adds hidden ordering edges |
lifecycle |
resource | replacement/destruction behavior flags |
provider |
resource, data | selects a non-default provider configuration |
provisioner |
resource | rare in-place bootstrap steps (legacy smell) |
Two of them rewrite the object's address, which is why they dominate interview questions. With count, every reference to the resource becomes a list and needs an index or a splat:
resource "aws_ebs_volume" "data" {
count = 3
availability_zone = "${var.region}${count.index}abc" # count.index: 0, 1, 2
}
resource "aws_volume_attachment" "data" {
for_each = toset(["blue", "green"])
volume_id = aws_ebs_volume.data[each.key].id # for_each: each.key / each.value
device_name = "/dev/sdf"
}
The consequence to internalize: switching a resource from count = 2 to for_each = toset(["blue", "green"]) changes every address — [1] becomes ["green"] — and Terraform will plan to destroy the old instances and create new ones unless you terraform state mv each one first. That is the practical reason teams reach for for_each whenever instances carry meaningful names: count's numeric indexes are fragile the moment the set is reordered or an element is inserted.
Meta-arguments interact with the type system too. count must be a number, for_each a map or set (a tuple errors with "Invalid for_each argument... must be a map or set"), and depends_on accepts only a list of whole object references — depends_on = [aws_iam_role.app] — never attribute references, because ordering is about objects, not values. Because Terraform recognizes these names itself, an unknown key is not silently ignored: cont = 3 fails validation with "Unsupported argument", your typo caught before it does nothing forever.
A compact example shows how the address-changing pair behaves on a realistic resource:
resource "aws_sns_topic" "events" {
for_each = toset(var.event_topics) # ["orders", "billing"] -> two instances
name = "${var.project}-${each.value}"
}
# addresses: aws_sns_topic.events["orders"], aws_sns_topic.events["billing"]
References to it take keys: aws_sns_topic.events["orders"].arn, or the splat values(aws_sns_topic.events)[*].arn when you want all of them. Two practical rules of thumb hard-won in production: prefer for_each whenever instances carry names you would recognize in a console, because a filtered or reordered list then produces targeted, reviewable diffs instead of index-shifted carnage; and treat count as the choice for anonymous, identical replicas where nothing meaningful can be removed from the middle. The lifecycle meta-argument — the fourth in the table — is complex enough to deserve its own advanced topic later; provider and depends_on are covered in depth in the resources and dependencies phases ahead.
Unknown Values: How a Plan Succeeds While Facts Do Not Exist Yet
Here is the sentence that confuses every newcomer, stated precisely: referencing an object that does not exist yet is legal, and the reference resolves to an unknown value at plan time. The plan output writes it as (known after apply):
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
}
output "public_ip" {
value = aws_instance.web.public_ip
}
+ resource "aws_instance" "web" {
+ id = (known after apply)
+ public_ip = (known after apply) # provider cannot compute it before the server exists
}
Nothing here failed. Terraform knows aws_instance.web will exist after this apply, and it knows the attribute's type; what it cannot know is the value the cloud will assign. So the value is typed and deferred, and the plan is still a complete, executable document: create the object, then compute dependents. This is precisely what lets one plan contain a whole dependency chain of resources that reference each other — the graph plus unknowns replace any need to run commands step by step.
Unknowns propagate like a type-preserving fog. Any expression built on an unknown produces an unknown of the result type: "i-${aws_instance.web.id}" is an unknown string; length(var.list) on a known list is known; an arithmetic op on an unknown number is an unknown number. Conditionals have the sharpest rule: if the predicate is unknown, the whole conditional is unknown of the branches' unified type — you cannot force Terraform to choose a branch from a fact the cloud has not supplied. Functions refuse to guess too: cidrsubnet(var.cidr, 4, 0) with an unknown var.cidr returns an unknown rather than an error, because evaluation is deferred, not broken.
Unknowns are also the reason a plan for a brand-new environment lists (known after apply) on most computed attributes while every configuration decision is already fully determined. The distinction to rehearse: arguments you wrote are known (they will be sent to the API as planned); attributes the provider reports back — IDs, ARNs, generated IPs — are unknown until the object exists. That is why plan review focuses on the arguments: they are the entire set of decisions you are making, and the unknowns are just the receipts. After the first successful apply the values become known — recorded in state, shown by terraform show — and later plans display the concrete old → new diff instead of a deferred placeholder, which is why perpetual (known after apply) on an attribute you expected to stabilize is itself a signal worth investigating.
The honest version of the keyPoint you will read in older material: referencing a not-yet-applied object does not make the plan fail — it makes the resulting value unknown until apply. Plan-time failures are reserved for references that can never resolve: undeclared resources, unsupported attributes, and — the interesting one — cycles. If resource A references B and B references A, dependency detection hits the impossibility head-on with "Cycle: aws_instance.a → aws_instance.b → aws_instance.a", naming the loop. Cycles are the graph refusing to lie about order, and breaking one usually means extracting a value to a third object or questioning whether the ordering you forced was ever real. Read the graph explicitly with terraform graph -type=plan (it prints GraphViz DOT), which turns "why is this waiting?" from archaeology into a picture.
Quiz
1. The expression `data.aws_ami.ubuntu.id` appears in a resource. What does Terraform resolve it to, and what does it establish?
2. A resource currently has count = 2 and is referenced as aws_instance.web[1].id. What happens when you change it to for_each over named keys?
3. Which key is a meta-argument interpreted by Terraform rather than passed to the provider?
4. A plan shows `+ subnet_id = (known after apply)` for a resource whose subnet is created in the same plan. What does this mean?
Flashcards
Question
Name the reserved reference scopes in HCL and what each traverses.
Click to reveal answer
Answer
`var.` input variables, `local.` local values, `data.` data sources, `module.` child module outputs, `each.` (key/value under for_each), `count.` (index under count), `terraform.` (workspace and meta info). Anything else begins with a resource type: `aws_instance.web.id` walks type → local name → attribute, with `[i]` indexes between segments.
Question
What is a meta-argument and what happens when you misspell one?
Click to reveal answer
Answer
A key in a block body that Terraform itself interprets instead of passing to the provider: count, for_each, depends_on, lifecycle, provider, provisioner. A misspelling (`cont`) is not ignored — validation fails with "Unsupported argument", catching the typo before it silently does nothing.
Question
What is an unknown value, where is it written in plan output, and how does it propagate?
Click to reveal answer
Answer
A typed value whose actual value cannot exist until apply, shown as `(known after apply)`. Any expression using it yields an unknown of the result type; a conditional with an unknown condition yields the branches' unified type as unknown; functions defer instead of erroring. It is legal in plans and does not indicate failure.
Question
Which references make a plan fail, and how do they differ from a reference to a not-yet-created object?
Click to reveal answer
Answer
Failing references can never resolve: undeclared resource ("Reference to undeclared resource"), nonexistent attribute ("Unsupported attribute"), bad key ("Invalid index"), or a reference loop ("Cycle: ..."). A reference to an object created in the same plan is valid — it resolves to an unknown value plus a dependency edge.
Question
How do count and for_each shape addresses, and what is the migration cost of switching between them?
Click to reveal answer
Answer
count produces `name[0..N-1]` numeric indexes; for_each produces `name["key"]` keys from a map or set (tuples are rejected). Because addresses live in state, switching mechanisms rewrites every address — the plan shows destroys and creates unless each instance is migrated with terraform state mv first.
Revision Notes
Key Takeaways
- 1. References are traversals through one namespace; reading them left to right localizes every resolution error the language can produce
- 2. `name = expression` is exactly one expression, and meta-arguments (count, for_each, depends_on, lifecycle, provider) are names Terraform consumes itself before the provider ever sees the body
- 3. `count` and `for_each` are address-generating: switching mechanisms rewrites state addresses and forces state migrations
- 4. Unknown values are typed deferrals, not failures: `(known after apply)` appears because the provider cannot compute the value until the object exists, and unknowns propagate predictably through expressions
- 5. Dependency edges start as references; cycles are detected and reported by name, which makes graph bugs visible instead of hung
Interview Tips
- • "Explain what happens when resource A references resource B's id." — an implicit dependency edge forms, the graph orders B's creation first, and B's generated id may be unknown at plan time; one plan covers both — no separate applies
- • "count or for_each?" — for_each when instances have meaningful names or the set is reordered/filtered, because numeric indexes shift; count for anonymous, uniform replicas; add the migration cost: switching mechanisms rewrites addresses unless you `terraform state mv`
- • "What is (known after apply), and is it an error?" — a typed deferred value the provider cannot compute until the object exists; not an error; it propagates through dependent expressions and resolves during apply
- • "How do you debug a 'Cycle' error?" — run `terraform graph -type=plan`, find the loop, and break it by removing a needless reference or extracting the shared value into a third object or local
Cheat Sheet
References, Meta-arguments, Unknowns
Scopes: var.x · local.x · data.aws_ami.ubuntu.id · aws_instance.web.id · module.vpc.id · count.index · each.key/each.value · terraform.workspace. Assignment: name = <one expression>; whole objects (aws_instance.web) are not scalars — descend with .id.
Error vocabulary:
| Message | Meaning |
|---|---|
| Reference to undeclared resource | type+name does not exist |
| Unsupported attribute | object exists, attribute does not |
| Invalid index | key out of range or missing |
| Unsupported argument | misspelled/meta-argument misuse |
| Cycle: a → b → a | dependency loop in the graph |
Meta-arguments: count (N instances, [0..N-1]) · for_each (map/set → one per element, ["key"]) · depends_on (whole-object ordering list) · lifecycle (replacement flags) · provider (aliased config) · provisioner (legacy in-place steps).
Unknowns: (known after apply) = typed deferred value; propagate through expressions; unknown condition ⇒ unknown conditional; terraform graph -type=plan prints the dependency DAG as DOT.