Conditional Expressions
The conditional expression — the ternary — condition ? true_value : false_value is how HCL chooses between two values inline: a smaller bucket in dev, a larger one in prod, a name with or without a suffix. Its most consequential use is the "create this resource or not" pattern, expressed either as count = var.enabled ? 1 : 0 or as for_each over an empty set, and the two forms leave a different footprint in state.
For the Hatch pipeline the optional resource shows up constantly: a dead-letter topic that only some environments want, a monitoring export that staging skips, a per-tenant dataset created only when the tenant opts in. Each is a conditional creation, and which construct you reach for decides whether toggling it later renumbers your state or leaves it stable. That choice is the heart of this topic.
The Ternary
var.is_prod ? "US" : "us-central1" picks one of two values. Both branches must be the same type — a string and a string, a list and a list — because HCL evaluates the expression to a single typed result and will not unify a string with a list. The expression is evaluated, not short-circuited around type errors: both arms are type-checked, so a type mismatch is a plan-time failure, not a runtime surprise on the branch you happened to take.
Conditional Resource Creation via count
count = var.create_dlq ? 1 : 0 creates the dead-letter topic in environments that want it and zero instances elsewhere. The resource becomes a list of length 0 or 1, addressed google_pubsub_topic.dlq[0] when it exists and absent entirely when it does not. This is the classic conditional-creation idiom, and it works — with one sharp edge covered two sections down.
resource "google_pubsub_topic" "dlq" { count = var.create_dlq ? 1 : 0 name = "hatch-events-dlq" }
Conditional Creation via for_each
for_each = var.create_dlq ? toset(["dlq"]) : toset([]) does the same thing — one instance when the condition holds, none when it does not — but keys the instance by "dlq" instead of [0]. The resource is addressed google_pubsub_topic.dlq["dlq"], which reads better in state and, crucially, interacts cleanly with other for_each resources. Toggling it on and off adds or removes the "dlq" key without disturbing anything else.
resource "google_pubsub_topic" "dlq" { for_each = var.create_dlq ? toset(["dlq"]) : toset([]) name = "hatch-events-dlq" }
count = cond ? 1 : 0 — addressed [0] when present. Existence is tied to a position, so every reference must index [0] and a flip can break it.for_each = cond ? toset(["dlq"]) : toset([]) — addressed by key. Toggling adds or removes the key, never renumbers. Prefer when referenced elsewhere.The count-flip Index Gotcha
Toggling count between 0 and 1 changes the resource from [0] existing to not existing. If other resources reference ...[0] — a subscription pointing at google_pubsub_topic.dlq[0].id, say — flipping the condition off makes that reference point at a non-existent index and the plan fails. Worse is combining a conditional count with a list count in one expression: now the index encodes both "does it exist?" and "which one is it?", and any change churns the lot. Keep "whether" and "which" out of the same count.
Defaulting and Coalescing
Not every conditional is a create-or-not. For "use this value if set, otherwise fall back," coalesce() returns its first non-null argument and try() returns the first argument that does not error — both express null-handling in one line where a nested ternary would sprawl. coalesce(var.bucket_location, "US") reads more cleanly than var.bucket_location != null ? var.bucket_location : "US", and the intent — default this optional variable — is legible at a glance.
count = cond ? 1 : 0 — addresses the optional resource as [0], which forces every reference to index [0] and entangles existence with position. Toggling it off breaks any reference that assumed the index existed. Acceptable only when nothing else references the resource.
for_each = cond ? toset(["x"]) : toset([]) — addresses it by a named key, so toggling it never renumbers anything and references stay stable across the flip. Prefer this form whenever the resource is referenced elsewhere, which is most of the time.
- Returning different types from the two ternary branches (a string versus a list) and getting an "inconsistent conditional result types" error — both arms must match.
- Referencing a conditionally-created resource as
...[0]without guarding for the count-zero case, so when the condition is false the reference points at a non-existent index and the plan fails. - Toggling a conditional
countfrom 1 back to 0 to "pause" a resource and discovering it is destroyed, not suspended — index 0 ceasing to exist is a delete. - Combining conditional creation and iteration in one
countexpression (var.enabled ? length(var.x) : 0), so the index now encodes both "whether" and "which," and any change churns the lot. - Using a deeply nested ternary for null-defaulting where
coalesce()ortry()would read in one line. - Assuming the ternary short-circuits like a guard, then hitting a type error on the branch that was never meant to run — both arms are type-checked regardless.
- Use the
for_each-on-empty-set form for optional resources that other resources reference, so toggling never renumbers state. - Keep both ternary branches the same type, and reach for
coalesce()ortry()instead of nested ternaries for fallbacks. - Guard references to conditionally-created resources with
one(google_pubsub_topic.dlq[*].id)or a length check instead of hardcoding[0]. - Keep "whether to create" and "how many to create" in separate expressions, never fused into one
count. - Default optional variables with
coalesce()at the point of use so a missing input has one obvious fallback.
Knowledge Check
Why must both branches of a ternary return the same type?
- HCL evaluates the expression to a single typed result and type-checks both arms, so a mismatch fails the plan
- The branch not taken is discarded at runtime, so only the type of the branch the condition actually selects ever matters
- Terraform silently converts both branches to strings, so any mix of types is allowed
- The condition itself must be one of the two branch types in order for the ternary to evaluate at all
How do conditional count and conditional for_each differ in state for an optional resource?
countaddresses it as[0], entangling existence with position;for_eachkeys it by name, so toggling never renumbers- They are identical in state; only the surface syntax of the meta-argument differs
for_eachstores the resource under two separate keys at once so a destroy of one still leaves the other in place for redundancycountretains the resource in state even at zero, whilefor_eachremoves it entirely
You toggle a conditional count from 1 back to 0 to pause a topic. What actually happens?
- The topic is destroyed — index 0 ceasing to exist is a delete, not a suspension
- The topic is preserved in state at index 0 but quietly stops receiving and delivering messages
- Nothing changes in state or in the plan until some later apply sets count back to 1
- The topic is archived and restored automatically when count returns to 1
When is coalesce() a better choice than a nested ternary?
- For null-defaulting an optional value — it returns the first non-null argument in one readable line
- When you need to create or skip a whole resource conditionally based on the value of a boolean flag
- When the candidate values are different types and must be unified into one
- Whenever the condition itself depends on a resource's computed attribute
You got correct