9 minutes
Cleaner Terraform: Stop Writing Backwards Conditionals
Introduction
“The master debugger does not ask ‘What is missing?’ but ‘What is present?’”
I was tasked with refactoring an existing Terraform module. Simple enough, right? Wrong. I found myself staring at my monitor for god knows how long, trying to figure out how the logic flows in these ternary conditions. My Terraform game was a bit rusty, and I kept second-guessing myself.
Was I missing something obvious? Why did this feel so backwards?
Turns out the problem wasn’t my rusty skills (or maybe it was, haha). But I realized it was actually the code itself. When you write Terraform logic for things like S3 buckets, RDS databases, security groups, or EC2 instances, backwards conditionals make you think about what’s missing first. But that’s not how we naturally think about defaults:
“If the value exists, use it. Otherwise, do this.”
But I keep seeing Terraform code that works against this natural flow. Let’s fix that and make your code easier to read and maintain.
The Problem: Backwards Conditionals
“Backward your condition flows, hmm. flow backward your mind must, yes.” - Master Yoda, probably
Here’s a common pattern I see when setting defaults. These are backwards conditionals because they test for absence first:
# This reads backwards and is confusing
locals {
instance_config = {
instance_type = var.instance_type == null ? "t3.micro" : var.instance_type
region = var.region == null ? "us-east-1" : var.region
environment = var.environment == null ? "dev" : var.environment
}
}
This reads like: “If instance_type doesn’t exist, use default, otherwise use the value.” That forces you to think about absence first, which feels backwards when setting defaults.
Better: Forward Conditionals
Here’s the same logic using forward conditionals that test for presence first. This matches how we naturally think about defaults and flows in one direction without mental gymnastics:
# This reads naturally
locals {
instance_config = {
instance_type = var.instance_type != null ? var.instance_type : "t3.micro"
region = var.region != null ? var.region : "us-east-1"
environment = var.environment != null ? var.environment : "dev"
}
}
Now it reads naturally: “If instance_type exists, use it, otherwise use default.” Much better!
Maybe I’m just not smart enough, but forward conditionals make peer reviews and refactoring existing code so much easier for me.
Cleanest: Skip the Conditionals
“The wise function speaks once what the conditional repeats thrice”
But we can skip the conditional debate entirely with Terraform’s coalesce()
function:
# Crystal clear what's happening
locals {
instance_config = {
instance_type = coalesce(var.instance_type, "t3.micro")
region = coalesce(var.region, "us-east-1")
environment = coalesce(var.environment, "dev")
}
}
The coalesce()
function returns the first value that isn’t null. No conditional logic needed, no forward vs backward thinking required - just clean, obvious defaults.
Real Examples That Matter
These aren’t the exact modules I was refactoring, but they exhibit the same backwards conditional patterns that had me staring at my screen for god knows how long.
S3 Bucket Setup
Here’s the pattern I kept seeing - backwards conditionals everywhere:
# Hard to follow backwards conditionals
resource "aws_s3_bucket" "app_buckets" {
for_each = var.buckets
bucket = each.key
tags = merge(
var.default_tags,
each.value.tags == null ? {} : each.value.tags,
{
Environment = each.value.environment == null ? "dev" : each.value.environment
Owner = each.value.owner == null ? "platform-team" : each.value.owner
}
)
}
# Better with forward conditionals
resource "aws_s3_bucket" "app_buckets" {
for_each = var.buckets
bucket = each.key
tags = merge(
var.default_tags,
each.value.tags != null ? each.value.tags : {},
{
Environment = each.value.environment != null ? each.value.environment : "dev"
Owner = each.value.owner != null ? each.value.owner : "platform-team"
}
)
}
# Cleanest with coalesce
resource "aws_s3_bucket" "app_buckets" {
for_each = var.buckets
bucket = each.key
tags = merge(
var.default_tags,
coalesce(each.value.tags, {}),
{
Environment = coalesce(each.value.environment, "dev")
Owner = coalesce(each.value.owner, "platform-team")
}
)
}
Much better! No more mental gymnastics to figure out what’s happening. The clean version took me seconds to understand instead of minutes.
Database Configuration
Another common pattern that shows how backwards conditionals compound. Every line forces you to think about absence first:
# Painful to read and debug
resource "aws_db_instance" "databases" {
for_each = var.databases
identifier = each.key
engine = each.value.engine == null ? "mysql" : each.value.engine
engine_version = each.value.engine_version == null ? "8.0" : each.value.engine_version
instance_class = each.value.instance_class == null ? "db.t3.micro" : each.value.instance_class
allocated_storage = each.value.storage_size == null ? 20 : each.value.storage_size
backup_retention_period = each.value.backup_retention == null ? 7 : each.value.backup_retention
storage_encrypted = each.value.encrypt_storage == null ? true : each.value.encrypt_storage
}
Forward conditionals make this much more readable:
# Better with forward conditionals
resource "aws_db_instance" "databases" {
for_each = var.databases
identifier = each.key
engine = each.value.engine != null ? each.value.engine : "mysql"
engine_version = each.value.engine_version != null ? each.value.engine_version : "8.0"
instance_class = each.value.instance_class != null ? each.value.instance_class : "db.t3.micro"
allocated_storage = each.value.storage_size != null ? each.value.storage_size : 20
backup_retention_period = each.value.backup_retention != null ? each.value.backup_retention : 7
storage_encrypted = each.value.encrypt_storage != null ? each.value.encrypt_storage : true
}
But coalesce is even cleaner:
# Cleanest with coalesce
resource "aws_db_instance" "databases" {
for_each = var.databases
identifier = each.key
engine = coalesce(each.value.engine, "mysql")
engine_version = coalesce(each.value.engine_version, "8.0")
instance_class = coalesce(each.value.instance_class, "db.t3.micro")
allocated_storage = coalesce(each.value.storage_size, 20)
backup_retention_period = coalesce(each.value.backup_retention, 7)
storage_encrypted = coalesce(each.value.encrypt_storage, true)
}
This version is so much easier to scan and understand. I can actually focus on the business logic instead of decoding the conditionals.
Security Group Rules
This pattern shows how backwards conditionals become a nightmare when nested. Each condition requires mental translation:
# Nested backwards conditions everywhere
locals {
security_rules = flatten([
for sg_name, sg_config in var.security_groups : [
for rule in sg_config.rules : {
sg_name = sg_name
type = rule.type
from_port = rule.from_port == null ? rule.port : rule.from_port
to_port = rule.to_port == null ? rule.port : rule.to_port
protocol = rule.protocol == null ? "tcp" : rule.protocol
cidr_blocks = rule.cidr_blocks == null ? ["0.0.0.0/0"] : rule.cidr_blocks
description = rule.description == null ? "Auto-generated rule" : rule.description
}
]
])
}
Forward conditionals help, but are still verbose:
# Better with forward conditionals
locals {
security_rules = flatten([
for sg_name, sg_config in var.security_groups : [
for rule in sg_config.rules : {
sg_name = sg_name
type = rule.type
from_port = rule.from_port != null ? rule.from_port : rule.port
to_port = rule.to_port != null ? rule.to_port : rule.port
protocol = rule.protocol != null ? rule.protocol : "tcp"
cidr_blocks = rule.cidr_blocks != null ? rule.cidr_blocks : ["0.0.0.0/0"]
description = rule.description != null ? rule.description : "Auto-generated rule"
}
]
])
}
Coalesce makes it crystal clear:
# Cleanest with coalesce
locals {
security_rules = flatten([
for sg_name, sg_config in var.security_groups : [
for rule in sg_config.rules : {
sg_name = sg_name
type = rule.type
from_port = coalesce(rule.from_port, rule.port)
to_port = coalesce(rule.to_port, rule.port)
protocol = coalesce(rule.protocol, "tcp")
cidr_blocks = coalesce(rule.cidr_blocks, ["0.0.0.0/0"])
description = coalesce(rule.description, "Auto-generated rule")
}
]
])
}
Now you can actually see what each rule is doing without getting lost in the conditional logic. This is the kind of clarity that makes refactoring so much easier.
When Backwards Conditionals Make Sense
Before we write off backwards conditionals entirely, there are cases where they’re actually the natural way to think about a problem:
# Testing for intentional absence - backwards conditional feels natural here
enable_monitoring = var.disable_monitoring == null ? true : false
auto_backup = var.skip_backup == null ? true : false
apply_immediately = var.maintenance_window == null ? true : false
In these cases, backwards conditionals match our thinking: “If the disable flag is missing, enable the feature.” But notice how the variable names create confusion too - enable_monitoring
based on disable_monitoring
forces you to think backwards even with good conditional logic.
The key is matching your conditional pattern to how you naturally think about the problem, but also naming variables clearly.
When to Use What
Use coalesce()
when:
- You’re setting simple defaults
- All values are the same type
- You want maximum readability
instance_type = coalesce(var.instance_type, "t3.micro")
Use forward conditionals when:
- You need more complex logic than just defaults
- You’re working with different data types
- You need conditional expressions
# Complex logic that coalesce can't handle
backup_policy = var.environment == "prod" && var.backup_enabled ? "strict" : "standard"
Use backwards conditionals when:
- Testing for intentional absence makes semantic sense
- The natural thought is “if this disable flag is missing…”
- You’re enabling features based on missing configuration
Avoid backwards conditionals for simple defaults because:
- They force you to think about absence first
- Makes your brain work harder for the common case
- More likely to cause bugs when refactoring
Make Your Variables Clear Too
Beyond conditional patterns, your variable definitions should also be clear about how nulls work. This prevents confusion before you even write the conditionals:
# What does null mean here?
variable "users" {
description = "Map of users to create"
type = map(object({
path = optional(string)
permissions = optional(string)
tags = optional(map(string))
}))
}
# Clear defaults and what to expect
variable "users" {
description = "Map of IAM users to create"
type = map(object({
path = optional(string, "/") # Defaults to root path
permissions = optional(string, "ReadOnly") # Defaults to minimal access
tags = optional(map(string), {}) # Defaults to empty tags
}))
}
With Terraform 1.3+ you can set defaults right in the optional()
function. This means you often don’t need null checking at all!
Why This Matters for Your Team
Clear null handling isn’t just about looking nice. It directly helps with:
- Code Reviews: Reviewers spend less time figuring out backwards conditionals
- Debugging: Errors in conditional logic are easier to spot
- New Team Members: They understand the code faster
- Maintenance: Refactoring is much less error-prone
Key Takeaways
“Code that reads like thought requires no translation”
- Use forward conditionals for defaults: Think “if it exists, use it” rather than “if it’s missing, use default”
- Use coalesce(): It’s the cleanest approach for simple defaults
- Try optional() defaults: Let Terraform handle nulls for you when possible
- Match conditionals to thinking: Choose the pattern that matches how you naturally think about the problem
Looking back at that refactoring task, I realize the problem wasn’t my rusty Terraform skills. It was backwards conditionals that forced me to think unnaturally. Now when I write Terraform, I don’t waste time staring at conditionals trying to figure out which way the logic flows.
Cleaner Terraform code means fewer bugs and happier teammates. Your future self will thank you for writing code that reads the way humans think.
And as I publish this, I can’t help but think about all the backwards conditionals I’ve inflicted on the world over the years. Sorry, future me. Sorry, teammates. The horror is real, but at least now we know better… right?
