Skip to main content
15 min read

Terraform Strings and Conditionals: The Complete Guide

Terraform Strings and Conditionals: The Complete Guide

Terraform has no if statement. It has no for loop in the sense most languages mean. What it has is expressions, and once you know the handful that matter, most of the "how do I do X in Terraform" questions collapse into the same few answers.

This covers building strings, testing them, and every flavour of conditional: values, attributes, resources and data sources.

TL;DR

  • Build strings with interpolation "${var.a}-${var.b}", join lists with join(",", list), split them back with split().
  • Substring test is strcontains(str, sub) on Terraform 1.5 and later, can(regex(...)) before that. contains() is for list membership, not substrings, and mixing them up is the most common mistake here.
  • There is no if/else. There is a ternary: condition ? a : b. Chain them for else-if.
  • &&, || and ! are the boolean operators. They do not short-circuit the way you might expect in every context, so keep both sides valid.
  • Make a resource conditional with count = var.enabled ? 1 : 0, and remember it becomes a list, so reference it as resource[0] or with one().
  • Make an attribute conditional with dynamic blocks, or set it to null to leave it unset.
  • Handle a value that might not exist with try(), coalesce() or lookup(), not with a conditional.

Prerequisites

  • Terraform 1.x installed
  • Familiarity with variable, locals, resource and output blocks

Building strings

Interpolation

The everyday case. Anything inside ${} is evaluated and its result inserted:

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

variable "app_name" {
  type    = string
  default = "checkout"
}

locals {
  bucket_name = "${var.app_name}-${var.environment}-assets"
  # checkout-dev-assets
}

You do not need interpolation when the whole value is a single expression. This is redundant:

name = "${var.app_name}"   # don't
name = var.app_name        # do

Terraform will warn you about it, and it is the single most common thing to clean up in an inherited codebase.

format() for anything with structure

When you are padding numbers or repeating a value, format() is clearer than a wall of interpolation:

locals {
  # web-001, web-002, web-003
  instance_names = [for i in range(1, 4) : format("web-%03d", i)]

  arn = format("arn:aws:s3:::%s-%s", var.app_name, var.environment)
}

formatlist() does the same across a list, which saves a for expression:

locals {
  urls = formatlist("https://%s.example.com", ["api", "web", "admin"])
  # ["https://api.example.com", "https://web.example.com", "https://admin.example.com"]
}

join() and split()

join() turns a list into a string. It is the answer to most "convert a list to a string" questions:

locals {
  azs = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]

  az_csv   = join(",", local.azs)    # eu-west-1a,eu-west-1b,eu-west-1c
  az_lines = join("\n", local.azs)   # one per line
}

split() goes the other way, which is how you accept a comma-separated variable from CI and turn it into a real list:

variable "subnet_ids_csv" {
  type    = string
  default = "subnet-aaa,subnet-bbb"
}

locals {
  subnet_ids = split(",", var.subnet_ids_csv)
}
Warning

split(",", "") returns [""], a list with one empty string, not an empty list. If the variable might be empty, guard it:

subnet_ids = var.subnet_ids_csv == "" ? [] : split(",", var.subnet_ids_csv)

For machine-readable output, jsonencode() beats hand-built strings every time:

policy = jsonencode({
  Version   = "2012-10-17"
  Statement = [{ Effect = "Allow", Action = "s3:GetObject", Resource = "${local.bucket_arn}/*" }]
})

Testing strings

Does this string contain that one

On Terraform 1.5 and later there is a function for it:

locals {
  is_prod = strcontains(var.environment, "prod")
}

Before 1.5, the idiom was a regex wrapped so a non-match does not error:

locals {
  is_prod = can(regex("prod", var.environment))
}

Or counting matches, which reads badly but works everywhere:

locals {
  is_prod = length(regexall("prod", var.environment)) > 0
}
Note

contains() is not the function you want here. contains(list, value) tests whether a list holds an exact element:

contains(["dev", "staging"], var.environment)  # list membership, correct
contains("production", "prod")                 # error, not a substring test

This trips people up constantly because the names are so close.

Prefixes, suffixes and case

locals {
  is_internal = startswith(var.hostname, "internal-")
  is_backup   = endswith(var.filename, ".bak")
  normalised  = lower(trimspace(var.user_input))
}

startswith and endswith also arrived in 1.5. Before that: substr(s, 0, length(prefix)) == prefix.

Conditionals

There is no if, there is a ternary

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

Both branches must return the same type. This fails, because one branch is a string and the other a number:

value = var.enabled ? "yes" : 0   # error

Else-if is a chain

There is no elsif. Nest the ternaries, and format them one per line or nobody will read it:

locals {
  instance_type = (
    var.environment == "production" ? "m6i.xlarge" :
    var.environment == "staging"    ? "t3.large"   :
    "t3.micro"
  )
}

Past three branches, a map lookup is clearer and easier to extend:

locals {
  sizes = {
    production = "m6i.xlarge"
    staging    = "t3.large"
    dev        = "t3.micro"
  }
  instance_type = lookup(local.sizes, var.environment, "t3.micro")
}

The third argument to lookup() is the default, and it is what stops an unknown environment blowing up the plan.

and, or, not

locals {
  needs_backup   = var.environment == "production" && var.data_tier
  is_lower_env   = var.environment == "dev" || var.environment == "staging"
  skip_approval  = !var.require_approval
}

Terraform evaluates both sides of && and ||. Do not rely on the left side guarding the right:

# both sides get evaluated, so this still errors when the list is empty
var.items != [] && var.items[0] == "x"

# do the safe thing instead
length(var.items) > 0 ? var.items[0] == "x" : false

When the value might not exist

This is where people reach for a conditional and should not. Three better tools:

locals {
  # first non-null, non-empty value
  region = coalesce(var.region, var.default_region, "eu-west-1")

  # map key with a fallback
  owner = lookup(var.tags, "Owner", "unassigned")

  # swallow the error from an expression that might not resolve
  vpc_id = try(data.aws_vpc.selected.id, null)
}

try() takes expressions and returns the first that evaluates without error. It is the right answer for optional nested structures:

port = try(var.config.network.port, 8080)

Conditional attributes

Setting an attribute to null unsets it

An attribute set to null behaves as though you never wrote it, which means you get the provider default:

resource "aws_instance" "app" {
  ami           = var.ami_id
  instance_type = var.instance_type

  # only set when the caller supplied one, otherwise provider default
  key_name = var.ssh_key_name != "" ? var.ssh_key_name : null
}

This is much cleaner than duplicating the whole resource behind a conditional.

dynamic blocks for optional nested blocks

You cannot put a ternary around a block. You can generate zero or more of them:

resource "aws_security_group" "app" {
  name   = "${var.app_name}-sg"
  vpc_id = var.vpc_id

  # zero blocks when the list is empty, one per entry otherwise
  dynamic "ingress" {
    for_each = var.allowed_cidrs
    content {
      from_port   = 443
      to_port     = 443
      protocol    = "tcp"
      cidr_blocks = [ingress.value]
    }
  }
}

For a single optional block, iterate over a list that is either empty or has one element:

dynamic "logging" {
  for_each = var.enable_logging ? [1] : []
  content {
    target_bucket = var.log_bucket
    target_prefix = "logs/"
  }
}

That ? [1] : [] pattern is worth committing to memory. It is how you say "this block, but only sometimes".

Conditional resources

count for on/off

resource "aws_cloudwatch_log_group" "app" {
  count = var.enable_logging ? 1 : 0

  name              = "/aws/app/${var.app_name}"
  retention_in_days = 30
}

The catch: the resource is now a list, so every reference changes:

# wrong once count is present
log_group = aws_cloudwatch_log_group.app.name

# correct, but blows up when count is 0
log_group = aws_cloudwatch_log_group.app[0].name

# safe either way, returns null when the list is empty
log_group = one(aws_cloudwatch_log_group.app[*].name)

one() takes a list of zero or one element and returns the element or null. It is the cleanest way to reference an optionally created resource.

for_each when there are several

count gets fragile when the set changes, because resources are addressed by index and removing the middle one re-indexes everything after it. for_each addresses by key instead:

resource "aws_s3_bucket" "data" {
  for_each = toset(var.bucket_names)
  bucket   = "${var.app_name}-${each.key}"
}

Remove a name from the middle of the list and only that bucket is destroyed. With count, you would have destroyed and recreated everything after it.

Warning

for_each keys must be known at plan time. If you build them from an attribute of another resource that does not exist yet, you get "Invalid for_each argument: the for_each value depends on resource attributes that cannot be determined until apply". Key off your input variables instead of computed attributes.

Conditional data sources

Same count trick, and the same list access on the way out:

data "aws_ami" "custom" {
  count = var.custom_ami_id == "" ? 1 : 0

  most_recent = true
  owners      = ["self"]

  filter {
    name   = "name"
    values = ["${var.app_name}-*"]
  }
}

locals {
  ami_id = var.custom_ami_id != "" ? var.custom_ami_id : one(data.aws_ami.custom[*].id)
}

This is the standard shape for "look it up only if the caller did not tell me".

The mistakes worth knowing about

Type mismatch across ternary branches. Both sides must agree. var.x ? "a" : null is fine because null fits any type; var.x ? "a" : 1 is not.

Forgetting the list after adding count. Adding count to an existing resource changes its address from aws_instance.app to aws_instance.app[0], and Terraform will plan a destroy and create unless you terraform state mv it.

Using contains() for substrings. Covered above, still the most common one.

Assuming boolean short-circuit. Both sides evaluate. Guard with a ternary rather than relying on &&.

split() on an empty string. Returns [""], not [].

Building JSON by hand. Use jsonencode(). Hand-built JSON breaks the first time a value contains a quote.

Wrapping up

Almost every Terraform expression question reduces to one of these: interpolate or format() to build a string, join/split to move between strings and lists, strcontains or can(regex(...)) to test one, a ternary or a map lookup to choose a value, null or a dynamic block to make an attribute optional, and count/for_each with one() to make a resource optional.

The two that save the most time in practice are try() for values that might not exist and one() for resources that might not exist. Both replace a conditional that would otherwise be wrong in some edge case.

For more Terraform, we have written about running Terraform for a specific resource only, removing a resource from state and Terraform best practices.

Published: 2026-08-06|Last updated: 2026-08-06T10:00:00Z

Found an issue?

Also worth your time on this topic