Skip to content
Infrastructure as Code iac hcl 4 min read

Conditional Expressions

Real infrastructure is rarely one-size-fits-all. A production environment needs a larger instance than a development sandbox, a public-facing service may or may not attach a load balancer, and the number of replicas often depends on a flag. HCL gives you a single, compact tool for these decisions: the conditional (ternary) expression. It lets you choose between two values based on a boolean condition without resorting to duplicated blocks or external templating.

The ternary expression

The conditional expression has exactly one form:

condition ? true_value : false_value

condition must evaluate to a boolean. If it is true, the whole expression evaluates to true_value; otherwise it evaluates to false_value. Both result values must be convertible to the same type, because Terraform has to know the type of the expression regardless of which branch is taken.

variable "environment" {
  type    = string
  default = "dev"
}

locals {
  instance_type = var.environment == "prod" ? "m6i.xlarge" : "t3.micro"
}

Here local.instance_type resolves to "m6i.xlarge" only when environment is "prod", and "t3.micro" for every other value. This works identically in OpenTofu, which shares the HCL2 expression language with Terraform.

Environment-based values

The most common use is selecting configuration per environment. Combine the conditional with input variables so the choice is driven by data rather than hard-coded:

variable "is_production" {
  type    = bool
  default = false
}

resource "aws_instance" "app" {
  ami           = "ami-0c02fb55956c7d316"
  instance_type = var.is_production ? "m6i.large" : "t3.small"

  root_block_device {
    volume_size = var.is_production ? 100 : 20
    volume_type = "gp3"
  }

  tags = {
    Name = var.is_production ? "app-prod" : "app-dev"
  }
}

A plan for the non-production case makes the chosen values explicit:

Output:

  # aws_instance.app will be created
  + resource "aws_instance" "app" {
      + ami           = "ami-0c02fb55956c7d316"
      + instance_type = "t3.small"
      + tags          = {
          + "Name" = "app-dev"
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Toggling a resource argument

Conditionals shine when an argument should sometimes be unset. Pairing a ternary with null tells Terraform to omit the argument entirely and fall back to the provider default:

variable "enable_logging" {
  type    = bool
  default = true
}

resource "aws_s3_bucket" "data" {
  bucket = "devcraftly-data"
}

resource "aws_s3_bucket_logging" "data" {
  count = var.enable_logging ? 1 : 0

  bucket        = aws_s3_bucket.data.id
  target_bucket = aws_s3_bucket.data.id
  target_prefix = "log/"
}

Using count = condition ? 1 : 0 is the idiomatic pattern for conditionally creating a resource: one instance when the flag is on, zero when it is off. See meta-arguments for the details of count.

Combining with variables and functions

The condition can be any boolean expression, so you can mix comparisons, logical operators, and functions:

variable "replica_count" {
  type    = number
  default = 0
}

variable "region" {
  type    = string
  default = "us-east-1"
}

locals {
  has_replicas = var.replica_count > 0
  is_us        = startswith(var.region, "us-")

  failover_mode = local.has_replicas && local.is_us ? "active-active" : "single"
}

Extracting intermediate booleans into named locals keeps the final conditional readable. See functions for built-ins like startswith, coalesce, and length that frequently feed into conditions.

Tip: For “use this value, or a fallback if it is null/empty,” prefer coalesce(var.name, "default") or try(...) over a ternary. They express intent more clearly and handle multiple fallbacks.

Nesting and readability

Conditionals can be nested to handle more than two outcomes, but readability degrades quickly:

locals {
  size = var.environment == "prod"
    ? "large"
    : var.environment == "staging" ? "medium" : "small"
}

A cleaner approach for multi-way selection is a lookup map, which scales to many cases without nesting:

locals {
  size_for_env = {
    prod    = "large"
    staging = "medium"
    dev     = "small"
  }

  size = lookup(local.size_for_env, var.environment, "small")
}

The lookup form is easier to extend and review than a chain of ternaries, and the third argument provides a safe default.

Type coercion gotcha

Both branches must agree on a type. Terraform converts to the most general common type, which can surprise you:

ExpressionResultNotes
true ? "a" : "b""a"Both strings — straightforward
true ? 1 : 21Both numbers
true ? "1" : 2"1"Number coerced to string
true ? [] : ["x"][]Both lists of strings
true ? null : 5nullnull allowed; type is number

Warning: Mixing incompatible types — for example cond ? "text" : ["list"] — produces an “Inconsistent conditional result types” error at plan time. Make both branches the same shape.

Best practices

  • Keep conditions simple; lift complex boolean logic into named locals so the ternary reads cleanly.
  • Use count = cond ? 1 : 0 to toggle whole resources, and arg = cond ? value : null to toggle a single argument.
  • Prefer coalesce, try, or lookup over deeply nested ternaries for fallbacks and multi-way choices.
  • Ensure both branches share a compatible type to avoid inconsistent-result-type errors.
  • Drive conditions from typed input variables (bool, string) rather than magic strings scattered through the config.
  • Validate the chosen branch with terraform plan before applying — the resolved values appear directly in the plan output.
Last updated June 14, 2026
Was this helpful?