The ternary operator ?:
The basic conditional:
condition ? value_if_true : value_if_false
Examples:
variable "env" { default = "dev" }resource "aws_db_instance" "main" {instance_class = var.env == "prod" ? "db.m5.large" : "db.t3.micro"
multi_az = var.env == "prod"
backup_retention = var.env == "prod" ? 30 : 1
skip_final_snapshot = var.env != "prod"
}
Both branches must be the same type. You cannot write var.x ? "string" : 42; it fails with "inconsistent conditional result types".
Nested ternaries are unreadable; use locals instead
# Bad
size = var.env == "prod" ? "large" : var.env == "staging" ? "medium" : "small"
# Better
locals { size_by_env = {prod = "large"
staging = "medium"
dev = "small"
}
instance_size = local.size_by_env[var.env]
}
Once you have more than two options, a map reads more easily than a chain of ? : ?.
try(expr, fallback): try it or move on
HCL often breaks on "the value is missing". For example, reading a key that is not in a map:
variable "config" {type = map(string)
default = {env = "dev"
}
}
# Fails if config has no region key
region = var.config["region"]
try() solves this: it tries to evaluate the expression, and if that fails, it takes the fallback:
region = try(var.config["region"], "us-east-1")
# The key's value if it exists, otherwise "us-east-1".
You can pass several fallbacks:
region = try(
var.config["region"], # 1st attempt
var.default_config["region"], # 2nd
"us-east-1" # last
)
The first one that evaluates successfully wins.
can(expr): a "will this work?" check
It returns true/false, with no value. This is useful in conditions:
validation { condition = can(regex("^[a-z][a-z0-9-]{2,62}$", var.bucket_name))error_message = "Bucket name must start with a letter, 3-63 chars, lowercase only."
}
regex() fails when it finds no match. can(regex(...)) is true when it matches and false when it fails. That is the right pattern for validation.
Another example:
locals {has_custom_region = can(var.config["region"])
}
coalesce(...): the first non-null value
It takes the first value that is not null:
region = coalesce(var.custom_region, var.default_region, "us-east-1")
If var.custom_region is null, it checks the next one, and so on. If they are all null, it fails.
This looks like try(), but the difference is:
coalesce()looks at null.try()looks at an evaluation error.
If you want "not null and not an empty string", coalesce() skips null on its own but keeps an empty string. Use compact() for lists, or check explicitly.
lookup(map, key, default): a map with a default
The older style of map access with a fallback:
tags = {Env = "prod"
}
env = lookup(tags, "Env", "unknown") # "prod"
team = lookup(tags, "Team", "unassigned") # "unassigned", key is missing
Equivalent:
env = try(tags["Env"], "unknown")
Both forms are valid. try() is more modern and more general.
Pitfalls
-
A ternary with mixed types is an error.
var.x ? 42 : "fallback"will not pass. Both branches, one type. -
try()can mask real errors. If you wrap everything intry(), you will miss a real bug in the expression. Use it surgically. -
can()masks too. If any error reachescan(...), it returns false, even when the cause is aregex()with a broken pattern. Be careful. -
coalesce()and empty strings.coalesce("", "fallback")returns"", not the fallback.""is not null. When you need "not empty", write it out:var.x != "" ? var.x : "fallback". -
Conditionals do not make if/else blocks. In HCL you cannot "create a resource only if". For that, use
count = var.create ? 1 : 0(creates 0 or 1 resource). The same applies to for_each:for_each = var.create ? toset(["one"]) : toset([]). -
?:inside a string, no wrapper needed.bucket = "name-${var.env == "prod" ? "p" : "d"}"is valid. Inside${...}you can use full expressions.