One Deployment File, Two Environments: Branching with ? :
Your team keeps a single configuration for dev and prod, and the differences are small: instance size, replica count, whether the NAT gateway is required. The ternary operator is how you express "this value depends on a fact" without splitting the configuration into copies:
locals {
is_prod = var.environment == "prod"
instance_type = local.is_prod ? "m6i.large" : "t3.micro"
replica_count = local.is_prod ? 3 : 1
nat_gateway = local.is_prod ? 1 : 0
}
resource "aws_instance" "web" {
count = local.replica_count
instance_type = local.instance_type
# ...
}
The form is condition ? true_value : false_value. The condition must evaluate to a boolean, the operator sits between any two expressions, and the result may be used anywhere an expression is legal — resource arguments, count and for_each, module inputs, other locals, output values, even inside another ternary.
The single most interview-relevant property: both branches are type-checked whether or not they will be selected. Terraform must know the type of the whole expression before it knows the condition's value in every case (conditions can be unknown until apply), so the true and false results must be convertible to one common type. local.is_prod ? "m6i.large" : "t3.micro" is trivially fine — both strings. var.enabled ? 3 : "1" also passes, because numbers convert to strings, making the unified result type string. But var.enabled ? {} : [] fails at plan with Inconsistent conditional result types, and the message names both branch types.
A second property is subtler and saves you from defensive clutter: errors in the unselected branch do not propagate. The HCL spec passes a branch's evaluation errors through only when that branch is chosen, which is why the idiomatic guarded access works:
subnet_id = length(var.subnet_ids) > 0 ? var.subnet_ids[0] : null
When var.subnet_ids is empty, the false branch returns null and the index-out-of-range error in the true branch never surfaces. You do not need try() around it.
Use ternaries for scalar decisions and keep them short. Three shapes cover nearly every real case:
| Goal | Expression |
|---|---|
| Pick between two values | var.env == "prod" ? "m6i.large" : "t3.micro" |
| Conditionally omit an argument | var.cert_arn == "" ? null : var.cert_arn |
| Toggle a whole resource | count = var.enable_monitoring ? 1 : 0 |
The count = ... ? 1 : 0 form carries a caveat worth planning for: the resource address becomes indexed (aws_cloudwatch_metric_alarm.http[0]), and flipping the condition later is a destroy-and-recreate of the indexed object, not a clean add — that lifecycle story belongs to Resource Lifecycle and Meta-Arguments.
A chain like a ? x : b ? y : z parses but reads badly in review; the moment there are three outcomes, prefer a lookup on a map or a for expression's if filter — both of which you already have from Locals for Derived Values and get deeper coverage in The Built-in Function Library. One more habit worth forming: name the predicate once in a local (local.is_prod) and reuse it, so a future change to the environment logic edits one line instead of hunting seven conditionals. And when a branch should mean "omit this argument entirely," null is the value that does it — that pattern is coming up next, because null inside a conditional has rules of its own.
Why Terraform Checks the Branch You Did Not Take
The plan fails with:
Error: Inconsistent conditional result types
The true and false result expressions must have consistent types. string
includes list, so type conversion is not possible.
You stare at the line: local.is_prod ? local.name : local.subnet_ids, convinced both sides are "just values." One of them is not the type you think — a toset() here, a lookup without a default falling through to null there — and Terraform is telling you it could not find any type both branches convert to. These errors stop being mysteries once you know the unification rules.
Rule 1: branches must unify. Terraform tries to find a single type both branches can convert to, and that unified type is the type of the whole expression:
| Branches | Unified result | Why |
|---|---|---|
"a" and "b" |
string |
already identical |
3 and "1" |
string |
number → string is a safe conversion |
true and 1 |
error | no conversion exists between bool and number |
{} and [] |
error | object and list have no common type |
["a"] and [3] |
tuple([string]) |
element-wise unification of tuples |
"x" and null |
string |
null converts to any type |
Note what is not on the list: bool and number do not convert in either direction in HCL. var.enabled ? 1 : false is a hard error, and the fix is to make both branches the same type yourself — var.enabled ? 1 : 0 — rather than hoping for coercion.
Rule 2: an unknown condition yields an unknown result, not a deferred evaluation. When the condition depends on something not known until apply (another resource's attribute, an AMI id from a data source you have not read), Terraform still evaluates and type-checks both branches, marks the result unknown, and defers the choice to apply. This is why a type error in the branches fails during plan even though "we would not even take that branch." It is also why a branch referencing a resource attribute is legal: the expression simply stays unknown until the dependency resolves.
Rule 3: null is a value, and it propagates. A branch evaluating to null participates in unification and produces a null result when selected. That has a concrete meaning in a resource argument: a null value behaves as if the argument were omitted, letting the provider use its default or raise a required-argument error. So instance_type = var.use_default_type ? null : var.instance_type is the correct way to hand control back to the provider — but only when the argument is genuinely optional; otherwise you have traded a plan-time error for an apply-time one.
Rule 4: sensitivity marks ride along. If either branch derives from a value marked sensitive, the result is masked in plan output no matter which branch is selected — a conditional cannot launder a secret into a visible string. The same propagation logic applies to unknowns: when one branch is unknown and the other is known, the result is unknown of the unified type, and every argument that consumes it becomes unknown with it. A conditional is a pure expression, not an isolation boundary.
The debugging workflow when the message is unhelpful: evaluate each branch in terraform console — local.name, type(local.name) — and compare the types. One console round trip beats re-reading the expression, because the error names the types while the bug is almost always in how one of the locals was constructed: a jsondecode that produced an object, a split that produced a list, a lookup that fell through to null.
There Is No Truthiness in HCL: What a Condition Actually Accepts
Engineers arriving from Python or JavaScript reach for condition = var.name and expect an empty string to be false. HCL refuses, and the refusal is the language doing you a favor:
Error: Incorrect condition type
The condition expression must be of type bool.
HCL has no C-style truthiness. A condition expression must be a bool, or a string that spells one — HCL converts exactly four strings to booleans: "true", "false", "1", and "0". Everything else is an error at evaluation time:
| Expression | Result |
|---|---|
var.enabled (declared bool) |
fine |
var.flag where flag is "true" (string) |
converts to true |
var.flag where flag is "yes" or "" |
error — not a bool |
length(var.list) |
error — numbers do not convert to bool |
var.count > 0 |
fine — comparison returns bool |
var.maybe_null when null |
error — "Null condition. Conditions must either be true or false." |
The number case deserves emphasis because it is the most common one: count = length(var.zones) ? 1 : 0 fails even when the length is nonzero. Booleans and numbers have no direct conversion in HCL, so write the comparison: length(var.zones) > 0 ? 1 : 0.
With the strict predicate goes a related trap in the other direction: the equality operators do not coerce. var.port == "443" is false when port is the number 443 — no error, just a silently wrong branch, because comparing different types simply is not equal. Compare like with like, and rely on the declared type constraint from Input Variable Declarations so both sides carry the same type.
When a value is optional-and-maybe-null, the tools that do treat empty as absent are the functions, not the operator. These four idioms cover the territory:
locals {
port = try(tonumber(var.port), 443) # unparseable input -> default
enabled = coalesce(var.enable_raw, false) # null or "" -> false
usable = can(tostring(var.raw_value)) # bool: is it usable as a string?
flagged = contains(["true", "1"], lower(tostring(var.flag_raw)))
}
Each returns something the condition grammar accepts, and each answers a different question. try evaluates expressions in order and returns the first that does not error. coalesce returns the first argument that is neither null nor an empty string — note that contract: it treats "" as absent because its documentation says so, while the conditional operator treats "" as an ordinary string value. can converts any expression's success or failure into the boolean a condition demands, which is the same trick validation blocks use. And contains turns a messy caller-supplied flag string into an explicit decision instead of hoping HCL will coerce it.
None of these are truthiness; they are explicit, auditable rules you can read in review. That is the boundary to internalize about HCL's conditionals: strict at the predicate, flexible at the branches. The condition must be an honest boolean, but the two result branches unify types, discard their unselected errors, and carry unknowns and sensitivity marks forward faithfully. Interview questions on this topic almost always probe one of those two halves — knowing which half a question is testing tells you which rule to recite. For the full set of building blocks that feed these predicates, the next topic is The Built-in Function Library.
Quiz
1. `var.enabled ? 3 : "one"` — what does Terraform do with this expression?
2. Why does `length(var.list) > 0 ? var.list[0] : null` work safely, while `var.list[0]` alone fails on an empty list?
3. Which condition expressions are accepted by HCL?
4. var.port is the number 443 and you write `var.port == "443" ? "https" : "http"`. What happens?
Flashcards
Question
What is the syntax of a conditional expression, and where can it appear?
Click to reveal answer
Answer
`condition ? true_value : false_value` — anywhere an expression is legal: resource arguments, count/for_each, locals, module inputs, outputs, and nested inside other conditionals. The condition must be bool-convertible; the result takes the unified type of both branches.
Question
Why are both ternary branches type-checked even when the condition is already known?
Click to reveal answer
Answer
Terraform must determine one result type for the expression regardless of the condition (which may be unknown until apply), so it unifies both branch types at plan time. Un-unifiable branches fail with "Inconsistent conditional result types" before any branch is selected.
Question
Which branch pairs unify and which are errors?
Click to reveal answer
Answer
Unify: number+string → string; null with anything; tuples element-by-element. Errors: bool+number (no conversion exists in either direction), object+list, and any pair with no common type. Explicitly cast with tostring()/tonumber() or make both branches the same type.
Question
What values are legal in condition position, and what is the classic mistake?
Click to reveal answer
Answer
A bool, or one of the four convertible strings "true"/"false"/"1"/"0". Classic mistake: expecting C-style truthiness — length(x), an empty string, 0, or null in condition position all error ("Incorrect condition type" / "Null condition"); write `length(x) > 0` and `x != ""` instead.
Question
When does a conditional produce an unknown value until apply, and what still happens at plan?
Click to reveal answer
Answer
When the condition depends on values unknown at plan (e.g. another resource's attribute). Both branches are still evaluated and type-checked at plan, the result is marked unknown of the unified type, and the branch is chosen during apply.
Revision Notes
Key Takeaways
- 1. The ternary is `condition ? a : b` and works anywhere an expression is legal; name the predicate in a local instead of repeating it
- 2. Both branches are unified to one type at plan time — number and string unify to string, bool and number do not unify at all
- 3. Errors in the unselected branch are discarded, which makes the guarded-index idiom `length(x) > 0 ? x[0] : null` safe without try()
- 4. HCL has no truthiness: conditions accept bools and the four convertible strings only; numbers, empty strings, and nulls are errors
- 5. Equality does not coerce, so cross-type comparisons silently return false — compare like with like
Interview Tips
- • "Why does Terraform type-check the branch I did not take?" — the expression's type must be known independently of the condition, which may be unknown at plan; cite unification and the Inconsistent conditional result types error
- • "What can you use as a condition?" — a bool or one of "true"/"false"/"1"/"0"; numbers do not convert, so `length(x)` must become `length(x) > 0`; this shows you know HCL differs from C-style languages
- • "How do you omit an optional argument conditionally?" — return null from a branch; explain that null behaves like omission and that it is only safe when the provider treats the argument as optional
- • "Ternary vs lookup vs for-if — when do you switch?" — ternary for two scalar outcomes, lookup/map for three or more keyed outcomes, for-expression `if` for filtering collections; mention readability as the deciding factor in review
Cheat Sheet
Conditional Expressions
Syntax: condition ? a : b — condition must be bool-convertible; a and b must unify to one type.
Condition legality:
| Value in condition | Verdict |
|---|---|
| bool | ok |
"true" "false" "1" "0" |
ok (converted) |
number (length(x)) |
error — no number→bool conversion |
"", other strings |
error |
| null | error — "Null condition" |
Unification: number+string → string · null+X → X · bool+number → error · object+list → error (Inconsistent conditional result types).
Key semantics:
- Both branches type-check at plan even if unselected; errors in the unselected branch are discarded (
len(x) > 0 ? x[0] : nullis safe). - Unknown condition → unknown result of the unified type; branch chosen at apply.
==never coerces:443 == "443"is false.- A null branch omits the argument (provider default) — only valid for optional arguments.
- Empty-as-absent lives in functions, not the operator:
coalesce(x, "default"),try(expr, fallback),can(...).