Terraform Professional: Tracing Variables, Locals, Outputs and for_each
Tracing a Terraform configuration means following a value from the resource argument that uses it back through locals, module arguments, outputs and input variables to the source that set it — and predicting which instances a for_each expression creates and how they are addressed. This lesson covers objectives 1c and 1d of the Terraform Infrastructure Engineer Professional (002) exam: how a variable gets its final value, what type conversion, null and validation do to it, how locals and outputs are scoped, how sensitive marks travel, and how for_each keys, addresses and references behave.
On this page10 sections
- How Terraform decides an input variable's final value
- Type constraints, optional() and silent object conversion
- Null, nullable and defaults
- Validation rules, including cross-variable conditions
- Local values: one namespace per module
- Tracing a value across modules and outputs
- Sensitive values in locals and outputs
- for_each: accepted values, each.key and instance addresses
- Referencing for_each resources and modules
- Instance identity: stable keys, count shifts and composite keys
- Determine a root variable's final value from environment variables, .tfvars files, auto-loaded files and command-line options, and predict what happens to undeclared names from each source
- Predict how object type conversion, optional() defaults, nullable and validation rules (including cross-variable conditions) change or reject an input
- Trace a wrong value hop by hop through resources, locals, module arguments and outputs, using terraform console and its -scope option
- Explain how sensitive marks propagate through locals, functions and module boundaries, and fix sensitive-output and sensitive for_each errors safely
- Choose valid for_each values, predict each.key, each.value and instance addresses for resources and modules, and reference for_each collections correctly
- Design stable for_each keys and predict the plan when keys change, elements are removed or keys collide
How Terraform decides an input variable's final value
A root module variable takes the value from the last source Terraform loads; the default is used only when no source assigns one. Terraform loads, in order: TF_VAR_ environment variables, terraform.tfvars, terraform.tfvars.json, every *.auto.tfvars / *.auto.tfvars.json file in lexical order of filename, and finally -var and -var-file options in the order they appear on the command line. Several sources assigning one variable is normal, not an error — the later one simply wins.
| Source | Loaded | If it names an undeclared variable |
|---|---|---|
default in the variable block | Fallback only | — |
TF_VAR_name environment variable | First | Silently ignored |
terraform.tfvars, then terraform.tfvars.json | Next | Warning; the value is discarded |
*.auto.tfvars files (by filename, so 20-team beats 10-base) | Next | Warning; the value is discarded |
-var / -var-file on the command line, in order | Last (wins) | -var: error that stops the run; -var-file: warning |
The right-hand column is a tracing tool. A misspelt name in a .tfvars file produces a warning and the real variable quietly keeps its default — so a production run that "ignored" a value usually has that warning in its log. A misspelt TF_VAR_ name leaves no trace at all, which makes it the hardest loss to spot. Files warn rather than fail because one shared .tfvars file often feeds several configurations.
All of these sources set root module variables only. A child module's variable can never be set from a .tfvars file, TF_VAR_ or -var (there is no module.x.name=... syntax): the root must declare its own variable and pass it down in the module block. Precedence across environments and .tfvars overrides as a release practice are covered with workspace and pipeline operations; here the skill is reading the result.
Type constraints, optional() and silent object conversion
Before a module sees a value, Terraform converts it to the declared type, and that conversion can change it without any error. For an object(...) type, attributes the type does not declare are discarded, and attributes marked optional(type, default) receive their default when the caller omits them or sets them to null. optional(type) with no default yields null when omitted.
variable "settings" {
type = object({
name = string
replicas = optional(number, 2)
})
}
# Caller passes: settings = { name = "api", replica = 5 }
# Module sees: { name = "api", replicas = 2 }The misspelt replica vanishes during conversion, so replicas counts as omitted and falls back to 2 — a typo in a module argument silently becomes a default. Primitive conversions also apply: the string "false" converts to the bool false and "5" to the number 5. When a value is "wrong but no error", compare the caller's attribute names with the type declaration before anything else. Note that optional() is an attribute modifier inside an object type; it is not a type you can give a whole variable.
Null, nullable and defaults
An input variable is nullable by default, so a caller that explicitly passes null overrides the variable's default with null. Setting nullable = false forbids null inside the module: a null argument then takes the default, or fails the plan if the variable has no default.
| Declaration | Caller passes null | Caller omits the argument |
|---|---|---|
default = 30 (nullable) | Module sees null | 30 |
default = 30, nullable = false | 30 | 30 |
No default, nullable = false | Error: no valid value | Error: required argument missing |
Object attribute optional(bool, true) | true (for that attribute) | true |
This matters whenever a root passes one of its own variables straight through (instance_type = var.override_type) and that root variable defaults to null: with a nullable module variable, the null wins and the resource receives no value. The fix that protects every caller is nullable = false on the module's variable, not a default in one caller. A validation block that rejects null turns the case into an error instead of defaulting it. Keep the two levels apart: a null attribute inside an object takes the optional() default, while a null for the whole variable replaces the variable's default (for example {}) and then any attribute access on it fails.
Validation rules, including cross-variable conditions
A validation block checks a variable's final value during plan; a false condition is an error that stops the plan and prints the block's error_message, which points straight at the variable that carries the bad value. Since Terraform 1.9 the condition may refer to other input variables (and other objects in the module), so rules such as "prod needs at least three instances" can live on the variable instead of in a resource precondition.
variable "instance_count" {
type = number
validation {
condition = var.environment != "prod" || var.instance_count >= 3
error_message = "prod needs at least 3 instances."
}
}When a rule fails to catch something, evaluate the condition by hand with the caller's exact values. Terraform string comparison is case-sensitive: if a caller passes "Prod", then var.environment != "prod" is true, the whole || is true and the rule passes. Normalising with lower() in the condition, or validating the allowed values of environment itself, closes that gap. Validation never downgrades to a warning and does not wait for apply when the values are known at plan.
Local values: one namespace per module
Local values are declared in locals { } blocks and referenced with the singular prefix local.<name>. They are scoped to the module: every .tf file in the directory shares one namespace, so a local defined in locals.tf is usable in main.tf with no import, and the same name defined in two blocks — even in different files — is a duplicate-definition error. File order never makes one definition win and Terraform never merges them; combine maps under different names with merge() in one place.
Locals do not cross module boundaries in either direction. A child module that writes local.name_prefix refers to its own local; if the prefix lives in the root, the child must declare an input variable and the root must pass name_prefix = local.name_prefix. Copying the locals block into the child works but leaves two definitions to drift. Names in different namespaces are different objects: var.env and local.env can hold different values in the same module, which is a classic tracing trap.
Tracing a value across modules and outputs
Values travel down through module block arguments into the child's declared variables, and up through the child's outputs as module.<name>.<output>. Nothing is inherited: a child never sees the root's variables or locals, and an output cannot send a value into a child. To find the source of a wrong value, follow the references hop by hop rather than matching names:
- The resource argument in the child (for example
bucket = "billing-${var.env}"). - The child's variable or local it reads.
- The argument the caller sets in its
moduleblock — arguments are matched by name, so their order is irrelevant, but swapped right-hand sides (project = var.environment) are a common bug. - The root variable, local or resource that feeds that argument, and finally where that root value comes from (see the precedence table).
If one caller of a shared module is wrong while others are right, the module is fine and the call site is the suspect. Check outputs too: an output that echoes its input (value = var.name) reports what was requested, not what was created — when the resource appends a random suffix, consumers get a name that does not exist. Outputs should expose resource attributes (aws_s3_bucket.this.bucket), which also gives consumers an implicit dependency; depends_on changes ordering, never a value.
terraform output (with or without -json) shows root module outputs only. A child's output reaches the CLI, or a terraform_remote_state consumer, only when the root re-exports it with its own output block; terraform refresh does not change that.
Inspecting values with terraform console
terraform console loads the configuration and current state and evaluates any expression — var.x, local.x, functions, resource attributes — without planning or applying. Values that depend on resources not yet created show as known after apply; -plan builds a plan first so results reflect it. By default it evaluates in the root module, where a child's locals are invisible (a parent can read only module.app["stage"].<output>). The -scope option moves evaluation into a module instance with its real arguments, e.g. terraform console -scope='module.app["stage"]' (an index for count, a key for for_each). Running the console inside the module's own directory instead treats it as a root and loses the caller's inputs. terraform show displays state or a saved plan, which do not store locals, and terraform validate prints no values.
Sensitive values in locals and outputs
A sensitive mark belongs to the value, and every value derived from it stays sensitive: interpolating it into a local, or passing it through sha256(), length(), substr(), tostring() or keys(), keeps the mark. Any output, in the root module or a child module, that carries a sensitive value fails the plan unless the output declares sensitive = true; writing sensitive = false does not unmark anything.
| Fix for a sensitive-output error | Effect |
|---|---|
sensitive = true on the output | Redacted in plan and apply output; terraform output -raw name or -json still returns the real value for a pipeline |
nonsensitive(expr) on the value | Removes the mark deliberately; the value prints in logs — use only when the derived value is safe, such as a hash of a token |
Removing sensitive = true from the source variable | Clears the error by un-redacting the secret everywhere it is used |
Marks also cross module boundaries. When a child output declared sensitive = true is passed into another module, the receiving variable carries the mark even if it does not declare sensitive itself. So a "sensitive value" error deep in one module can originate in a different module's output — trace the value back to where it was first marked.
for_each: accepted values, each.key and instance addresses
for_each accepts exactly two kinds of value: a map (of any element type) or a set of strings. Lists, tuples, and sets of objects are rejected with an Invalid for_each argument error; Terraform never converts them implicitly, because position and duplicates mean nothing as instance keys.
| Value | each.key | each.value | Example address |
|---|---|---|---|
Map { db_port = "5432" } | The map key, "db_port" | The map value, "5432" | aws_ssm_parameter.p["db_port"] |
| Set of strings | The member | The same member | github_team.t["data"] |
List — wrap in toset() | The member | The same member | toset() drops duplicates and order: ["platform","data","platform"] gives two instances |
List of objects — build a map: { for o in var.items : o.name => o } | The chosen key, e.g. o.name | The object | res.x["web"] |
Instances are addressed by key, never by number (numeric indexes belong to count), and never by value. On a module block, for_each indexes the module instance: a resource inside without its own for_each is module.network["prod"].aws_vpc.main, not module.network.aws_vpc.main["prod"]. These are the addresses that terraform state show and plan output use.
Keys must be known, and must not be sensitive
Terraform must know every instance address while planning, so map keys and set members must be known at plan time; map values may be unknown. Keying a map by a provider-computed attribute (an ARN, an ID, random_id.x.hex) or by an impure function like uuid() fails in a new workspace with an error that keys cannot be determined until apply. Key by values you wrote — usually the upstream map key: { for k, f in aws_lambda_function.fn : k => f.role }. depends_on cannot make an unknown key known.
Sensitive values are also rejected as for_each arguments, because keys appear in plan output and state addresses. Unmark only the keys: toset(nonsensitive(keys(var.db_users))) — keys() keeps the mark and returns a list, so both wrappers are needed — while arguments such as password = var.db_users[each.key] still read the sensitive map and stay redacted. Never key instances by the secret values themselves.
Referencing for_each resources and modules
In expressions, a resource or module that uses for_each is a map of instance objects keyed like its for_each. Splat expressions ([*] or the legacy .*) do not iterate a map, and tolist() cannot convert one, so convert first:
# list of every instance's attribute
values(aws_instance.app)[*].private_ip
[for m in module.queue : m.arn] # one symbol = the values
{ for k, m in module.queue : k => m.arn } # two symbols: k is the key stringOne map can drive another: for_each = aws_vpc.this gives the dependent resource one instance per VPC with the same keys, and inside it each.value is the whole upstream object, so vpc_id = each.value.id (or aws_vpc.this[each.key].id). When the two maps have different keys, index with a value that matches the other map's keys — a subnet keyed prod-a that carries vpc = "prod" uses aws_vpc.this[each.value.vpc].id; indexing with its own each.key fails with Invalid index. Passing each.value where a string is expected, or reading an attribute from the key string, are the usual mistakes.
Instance identity: stable keys, count shifts and composite keys
Terraform matches configuration to state by address, so an instance's identity is its key (for for_each) or its position (for count).
| Change | With count | With for_each |
|---|---|---|
| Remove the first of three elements | Later elements shift down an index: indexes 0 and 1 get new values (replaced if the argument forces replacement) and index 2 is destroyed | Only the removed key's instance is destroyed |
| Rename a key, nothing else | — | Old address destroyed, new address created, even with identical attributes, unless a moved block maps old to new |
| Insert an element early in a list | Everything after it shifts | Unaffected if keys come from content |
So for_each keys should come from stable content, never from list indexes ({ for i, g in local.grants : tostring(i) => g } has the same shifting problem as count). When building a map with a for expression, each key must be unique or the plan fails with Duplicate object key. Two responses, for two different requirements:
- One instance per unique combination: build a composite key, e.g.
"${g.user}-${g.db}" => g, so each pair gets its own stable address.distinct()only removes identical objects and does not fix colliding keys. - One entry per key, collecting the rest: add
...after the value (grouping mode):{ for u in local.users : u.team => u.name... }givesdata = ["ana", "li"]. Each value is then a list, soeach.value.attrno longer works.merge()of single-key maps silently keeps the last value for a repeated key.
Moving state addresses with moved blocks and state commands is covered with state operations; for tracing, recognise that a key change is a destroy-and-create in the plan.
Tip. Expect short configurations in the stem and a question about the result: which source supplies a variable's value, what an undeclared name does from each source, what an object-typed variable contains after conversion, or what nullable = false does with and without a default. Tracing items show a wrong name or value in a plan and ask where it originates — a module argument, a swapped wiring, an output that echoes its input, a local that is never used, a case mismatch in a validation rule. for_each items ask for the expression that fixes an invalid, unknown-key or sensitive for_each error, the address of an instance (including inside a module created with for_each), how to reference a for_each collection, and what the plan proposes after a key rename, an element removal under count, or a key collision. Several items add a constraint — keep secrets redacted, fix every caller, keep addresses stable — that rules out a fix which would otherwise work.
- Last loaded wins: TF_VAR_ < terraform.tfvars < *.auto.tfvars (by filename) < -var/-var-file in order; the default is only a fallback.
- Undeclared names: TF_VAR_ is silently ignored, a .tfvars file only warns, -var is an error — and every one of these sources sets root variables only.
- Object conversion silently drops undeclared attributes; optional(type, default) fills attributes that are omitted or null.
- A nullable variable passed null gets null; nullable = false makes null take the default, or fail when there is no default.
- Locals are module-scoped across files, referenced as local.name, and never visible to child or parent modules; values go down through arguments and up through outputs.
- terraform output shows root outputs only; terraform console evaluates any expression, and -scope evaluates inside a child module instance.
- Sensitivity follows the value through functions, locals and module outputs: mark root outputs sensitive, and unmark only what becomes a for_each key.
- for_each takes a map or a set of strings with keys known at plan; instances are addressed by key, form a map (no bare splat), and renaming a key is destroy and create.
Frequently asked questions
Which value wins when a Terraform variable is set in several places?
The last source Terraform loads wins. It reads TF_VAR_ environment variables first, then terraform.tfvars and terraform.tfvars.json, then *.auto.tfvars files in lexical filename order, and finally -var and -var-file options in the order given on the command line. The variable's default applies only when no source assigns a value.
Why did Terraform ignore a value I set in a .tfvars file?
Usually because the name in the file does not match a variable declared in the root module — a typo, or a variable that exists only in a child module. Terraform prints a warning about a value for an undeclared variable, discards it, and the real variable keeps its default. Child module variables must be declared in the root and passed down in the module block.
What does nullable = false do on a Terraform variable?
It stops the variable from ever holding null inside the module. If a caller passes null explicitly, Terraform uses the variable's default instead; if the variable has no default, the plan fails. Without nullable = false, an explicit null overrides the default.
Can a Terraform variable validation block reference another variable?
Yes, since Terraform 1.9 a validation condition can refer to other input variables and objects in the module. The condition is evaluated during plan, and a false result stops the plan with the block's error_message. String comparisons in the condition are case-sensitive.
Why can't I use a splat expression on a resource that uses for_each?
A resource or module with for_each is a map of instance objects keyed by each.key, and splat expressions work on lists. Convert first with values(resource)[*].attribute, or use a for expression such as [for r in resource : r.attribute].
How do I fix 'sensitive values cannot be used as for_each arguments'?
Unmark only the part that becomes the instance key, typically with toset(nonsensitive(keys(var.map))), and keep reading the secret values from the sensitive map inside the resource so they stay redacted. The mark may come from another module's sensitive output, because marks follow the value across module boundaries.
Why must for_each keys be known during plan?
Terraform has to decide which instance addresses exist before it can build a plan. Keys built from provider-computed attributes such as ARNs or IDs, or from impure functions like uuid(), are unknown in a new workspace and fail. Key the map with values from configuration; the map's values may still be unknown until apply.
How do I see the value of a local inside a child module?
Run terraform console from the root with -scope set to the module instance address, for example terraform console -scope='module.app["stage"]', then evaluate local.name. From the root scope a child's locals are not reachable, and running the console inside the module directory loses the caller's arguments.
Source
This lesson covers the "Scalable Terraform configuration design" domain of the official Terraform Pro 002 exam guide. Vendors revise their guides — check the source for the current version.
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.