How to Use Conditionals: Terraform's if Statement Alternative
Terraform never got an if statement, and it doesn't need one. The ternary conditional expression that does the job is very much like an if, but if/else and condition ? true_val : false_val are very different once you get past a single toggle.
Pretty much every other language you've worked in has taught you that decisions look like if (condition) { do_this } else { do_that }. HCL doesn't have that block anywhere in its grammar. Instead, it has a single expression that does the same job with different mechanics, and once you see where those mechanics diverge from what you're used to, the translation gets a lot less confusing.
This article covers where the conditional expression behaves like the if/else you know, and where it doesn't, starting with the syntax itself and moving through where and how engineers actually use it.
Conditionally creating resources, gating whole modules, generating dynamic nested blocks, and building logic with more than two branches are all jobs for conditionals. We then close with the specific mistakes that catch people who are still thinking in if/else rather than in Terraform's terms.
Why Terraform doesn't have if statements
There's no if, no else, no switch, and no block-scoped branching in HCL's configuration syntax. The one place an if appears at all is inside string templates, where the %{ if } directive can vary text output, but even that only ever selects between values.
Terraform is a declarative language: your configuration describes the infrastructure you want to exist, not a sequence of instructions for getting there, which is what a block-scoped if statement does.
Python's if tells the interpreter which lines to run next. Terraform's job is different: it reads your entire configuration, resolves every expression in it, and produces one desired end state, which doesn't require branching execution the way a script does.
That is why there is no drop-in swap. An if statement in a traditional programming language can contain arbitrary code, side effects, loops, anything. Terraform's conditional expression can only ever choose between two values. It doesn't run a branch; it evaluates to one. With that in mind, conditionals feel like the right tool that Terraform gives you for the job.
The conditional expression is Terraform's if statement
The whole syntax is one line: condition ? true_val : false_val. If condition resolves to true, the expression evaluates to true_val. If it's false, you get false_val. That's the entire ternary operator, and it's the closest thing HCL has to an if clause.
condition has to be a boolean expression, typically built from equality, comparison, or logical operators, and it can live almost anywhere Terraform accepts a value, such as an input variable, a resource argument, an output, or a local.
The single most common use case is replacing invalid values with a sensible default:
If var.notification_email comes in as an empty string, local.notification_email falls back to the hardcoded address; otherwise it passes the actual value straight through.
This example is close to what if does in other languages, which is exactly why this pattern is where most people's translation of if into Terraform starts.
One rule worth knowing: both branches need to resolve to the same type, or to types Terraform can convert to a common one automatically (numbers convert to strings, for instance, so a condition returning 12 on one side and "hello" on the other is technically valid HCL).
Don't rely on that automatic conversion when a branch's type is genuinely ambiguous; use tostring() or tonumber() to document what you actually meant instead of leaving Terraform to guess.
How to create conditional resources with a Terraform "if" statement
If you are looking for a "terraform if statement," you are usually searching for something to do one specific job: conditionally create an entire resource, rather than just picking between two values.
The mechanism is the count meta-argument, and it works because count accepts any expression that resolves to a whole number, including a ternary that resolves to 1 or 0:
When var.enable_monitoring is true, count resolves to 1, and Terraform creates one log group, addressable as aws_cloudwatch_log_group.app[0]. Set it to false, and the resource doesn't exist at all after the next apply. It's not disabled, just absent from both the configuration's effect and the state.
count isn't the only tool for this job, and it isn't always the right one. When you need a set of resources that toggle independently rather than a single instance you're switching on or off, for_each gives each one a stable identity keyed to a value instead of a numeric position:
Add or remove an entry from var.monitored_services and only that one log group is created or destroyed; every other resource keeps its identity untouched.
count, by contrast, identifies each resource instance by its position in the list, which means removing an item from the middle of a count-driven collection shifts every subsequent index and can trigger a cascade of unnecessary destroy-and-recreate operations – a gotcha covered in full in our piece on Terraform conditional logic.
The equivalent of an if statement in Terraform modules
The same count/for_each pattern that gates a single resource gates an entire module, which enables you to conditionally create resources at the scale of a whole reusable unit rather than one resource at a time:
With create_vpn_gateway set to false, the module never runs, and none of the resources inside it get created. Flip it to true, and the whole module executes, addressable through module.vpn_gateway[0], the same indexing rule that applies to a count-driven resource.
If the module is meant to be reused across teams, consider pushing the boolean into the module as its own input variable, so the decision lives with whoever's calling it rather than with the module's own source:
Every caller of this module gets flow logs turned off by default and opts in with a single line, enable_flow_logs = true, without touching the module's own code. That example is the module-level version of an if statement: the branching logic still lives inside the module, but the condition that drives it is an input variable exposed to whoever's using it.
Writing conditional logic inside dynamic blocks
Resources and modules aren't the only things an if statement's logic needs to gate. Plenty of resource types have nested blocks, repeatable inline configuration like an EC2 instance's extra EBS volumes. To generate one of those nested blocks only when a condition is met, you need a dynamic block whose for_each collection is itself the product of a conditional expression:
When var.allow_admin_ssh is false, for_each over an empty list, the dynamic block produces nothing, and the instance launches with only its root volume. Set it to true, and the single-element list [22] generates exactly one ingress block. The reality is that a dynamic block's condition isn't a boolean directly; it's a boolean translated into a list with either one element or zero, since for_each only ever accepts a collection, not a raw condition.
How to write conditional logic with more than two branches
A single ternary handles two branches cleanly. The moment you need a third, the natural instinct (nesting another ternary inside the false_val of the first) works, but it's not the best way to do it:
Two levels of nesting is still readable. Four or five levels deep, chasing the logic of an if/elif/elif/else chain through parentheses, is exactly the kind of code that makes the person maintaining it six months later (possibly you) wish Terraform had kept the if keyword after all.
Once you get past two branches, use a map lookup:
The map reads as a flat lookup table instead of a chain of decisions; lookup()'s third argument supplies the default for any environment not listed, and adding a fourth environment later means adding one line to the map instead of another level of nesting.
coalesce() olves an adjacent problem, returning the first of its arguments that isn't null or an empty string, which is a clean way to layer several optional overrides down to a hard default without a single ternary:
Both patterns give up something a straight ternary chain has: they can only pick between values that are already computed, not run different logic per branch. For most of what an equivalent to an if/elif/else chain is actually doing in infrastructure code (selecting a value based on a category), that is a decision worth making.
How to simplify complex Terraform conditions
The other lever for keeping conditional logic readable is what goes inside the condition itself, not just how many branches follow it. A long boolean expression crammed directly into a ternary is harder to read than the same logic pulled out into a named local value:
local.is_production_ha_target documents what the condition means, not just what it evaluates, and the ternary that consumes it stays short enough to read quickly. The same instinct applies to checking a condition across an entire collection instead of chaining && across a handful of individual terms: alltrue() combined with a for expression tests every element at once.
can() enables you to test whether something is even safe to evaluate before deciding on it, returning true if the wrapped expression would succeed and false if it would error, which is a concise stand-in for validating a naming pattern with regex() before using it in a condition:
See our breakdown of the regex function for what it actually returns and where it can surprise you.
None of these functions replace the conditional expression itself; they clean up what goes into the condition, which is usually where a complex if-statement translation actually becomes unreadable, not in the ternary syntax itself.
Common errors when using Terraform conditions
Most mistakes here trace back to the same root cause: treating the conditional expression as if it had the full flexibility of an if statement in an imperative language, when it doesn't.
Ambiguous type conversion
Both branches of a ternary need to resolve to the same type, or a type Terraform can convert to automatically, and that automatic conversion is more permissive than it looks.
var.enabled ? 1 : "disabled" is valid HCL; Terraform converts 1 to "1" and the whole expression resolves to a string in both branches.
That result is rarely what you actually wanted, and it fails quietly rather than loudly, since it's valid syntax the whole way. Instead, use explicit tostring()/tonumber() whenever the two branches aren't obviously the same type already.
A bare conditional split across lines
This one is purely syntactic, and it catches people the moment a condition gets long enough that writing it on one line feels wrong:
Wrapping the whole expression in parentheses fixes it:
Assuming a toggled resource just moves
A resource with a count that flips from 1 to 0 doesn't shift position; it's gone entirely, and so is every downstream reference that points at it by index.
The conditional-logic breakdown linked earlier in this article covers that mechanic in full; every place downstream that reads resource[0] needs its own matching guard, because Terraform won't infer one from the resource's own count argument.
Letting a bad condition pick the wrong branch silently
A precondition block, placed in a resource's lifecycle block, stops Terraform during plan with an explicit error message if an assumption doesn't hold. It is a better solution than a ternary for catching a broken input before it silently selects a branch you didn't intend:
Run terraform plan against a disallowed instance class, and Terraform stops with that message instead of letting a bad value pick a branch that only fails later, deeper in the apply.
It's the same discipline a dry run is built around generally: catching a mistake before it touches real infrastructure, not after.
When if-statement sprawl becomes a coordination problem
Everything above assumes one engineer, one module, one state. A boolean toggle like enable_flow_logs or create_vpn_gateway is simple to reason about in isolation.
However, friction often shows up at enterprise scale, once the same kind of toggle is scattered across dozens of modules maintained by different teams, and some of those toggles reference resources that live in a separate Terraform state than the one flipping the condition.
At that point, it no longer just matters if your ternary is readable; it also matters who else's apply is touching a resource your condition just created or removed, and if you are both running right now.
Terraform's own dependency graph reveals what depends on what else within a single state and a single run, but it won't tell you if a second team is applying against a state that references a resource yours just toggled off, at the same time you're running.
Stategraph's platform picks up the slack here, past where a conditional expression's own syntax can help you.
Multi-state transactions plan and apply a change spanning several states as a single Terraform run, resolving the terraform_remote_state references between them automatically instead of applying each state separately and hoping the order works out.
Resource-level locking checks for overlap on the specific resources a transaction touches rather than locking an entire state file, so two teams working on unrelated resources, even inside the same state, can plan and apply concurrently; only a transaction that genuinely overlaps another gets rejected at commit and has to re-plan against the latest state.
Neither of these changes how you write the conditional expression itself. What changes is whether flipping a shared toggle across team boundaries is safe to do concurrently.
Conclusion
Terraform never got an if statement, but it still provides you with the tools you need to get the same results: the ternary expression for picking a value, count and for_each for conditionally creating resources and gating modules, a gated dynamic block for nested configuration, and a map lookup or coalesce() chain once a nested ternary stops being readable.
Ambiguous type conversion, a bare multi-line conditional split without parentheses, a toggled resource that vanishes instead of shifting, and a bad condition picked silently instead of caught by a precondition all trace back to someone expecting Terraform's conditional expression to behave exactly like the if statement they already know.
Try Stategraph free to see how multi-state transactions and resource-level locking keep a shared conditional toggle safe once more than one team depends on it.
Terraform if statement FAQs
Does Terraform support switch statements?
Not directly; no dedicated switch keyword exists in HCL. The map-lookup pattern is the practical equivalent: define a map keyed by the value you'd otherwise switch on, then use lookup() (or direct key indexing) to select the matching value, with lookup()'s third argument supplying a default for any key the map doesn't contain.
Can I use an if statement inside a for expression?
You can achieve the equivalent in Terraform, as a ternary can appear as the value expression inside a for expression, letting you transform each element differently depending on a per-element condition. For example, [for v in var.instances : v.size == "large" ? v.size : "standard"] applies the conditional to every element of the list independently as the for expression builds its result.
What's the difference between count and for_each for a conditional resource?
count set to var.enabled ? 1 : 0 is the simpler pattern for a single all-or-nothing resource, but it identifies each instance by numeric position, which can shift indexes and trigger unwanted recreation if the underlying list changes.
for_each keyed by a set or map gives each resource a stable identity tied to its key instead of its position, which is the safer default once you're toggling more than one resource independently.
How do I write a multi-line if expression without a syntax error?
Wrap the entire conditional expression in parentheses. Without them, Terraform's parser treats a newline as the end of the argument's definition and rejects a ternary split across lines; with the expression enclosed in ( ... ), the newlines inside become insignificant to the parser, and you can format the condition and both branches on their own lines for readability.