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>
- Build strings with interpolation
"${var.a}-${var.b}", join lists withjoin(",", list), split them back withsplit(). - 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 asresource[0]or withone(). - Make an attribute conditional with
dynamicblocks, or set it tonullto leave it unset. - Handle a value that might not exist with
try(),coalesce()orlookup(), 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>
- Terraform 1.x installed
- Familiarity with
variable,locals,resourceandoutputblocks
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>
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>
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
<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>
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()
<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>
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
<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>
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>
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 testThis 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>
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
<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>
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>
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
<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>
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
<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>
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
<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>
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
<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>
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>
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
<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>
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
<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>
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>
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
<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>
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_eachkeys 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>
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
<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>
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>
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)