DEV Community

DevOps Daily
DevOps Daily

Posted on • Originally published at devops-daily.com on

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

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode
  • 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

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode
  • Terraform 1.x installed
  • Familiarity with variable, locals, resource and output blocks

Building strings

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

Interpolation

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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"]
}

Enter fullscreen mode Exit fullscreen mode

join() and split()

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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}/*" }]
})

Enter fullscreen mode Exit fullscreen mode

Testing strings

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

Does this string contain that one

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

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

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

Enter fullscreen mode Exit fullscreen mode

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

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

Enter fullscreen mode Exit fullscreen mode

Or counting matches, which reads badly but works everywhere:

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

Enter fullscreen mode Exit fullscreen mode

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

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode
locals {
  is_internal = startswith(var.hostname, "internal-")
  is_backup = endswith(var.filename, ".bak")
  normalised = lower(trimspace(var.user_input))
}

Enter fullscreen mode Exit fullscreen mode

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

Conditionals

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

There is no if, there is a ternary

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode
locals {
  instance_type = var.environment == "production" ? "m6i.xlarge" : "t3.micro"
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Else-if is a chain

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

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"
  )
}

Enter fullscreen mode Exit fullscreen mode

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")
}

Enter fullscreen mode Exit fullscreen mode

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

and, or, not

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode
locals {
  needs_backup = var.environment == "production" && var.data_tier
  is_lower_env = var.environment == "dev" || var.environment == "staging"
  skip_approval = !var.require_approval
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

When the value might not exist

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

Conditional attributes

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

Setting an attribute to null unsets it

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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

dynamic blocks for optional nested blocks

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

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]
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

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/"
  }
}

Enter fullscreen mode Exit fullscreen mode

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

Conditional resources

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

count for on/off

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode
resource "aws_cloudwatch_log_group" "app" {
  count = var.enable_logging ? 1 : 0

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

Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

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}"
}

Enter fullscreen mode Exit fullscreen mode

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

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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

The mistakes worth knowing about

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

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

      <svg fill="none" stroke="currentColor" viewbox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
        </svg>
      </button>
Enter fullscreen mode Exit fullscreen mode

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.

Originally published at devops-daily.com.

Top comments (0)