Skip to content

Expressions, Functions, and Conditionals

Terraform’s expression language ships with built-in functions for transforming values, a terraform console REPL for checking what an expression evaluates to before it goes anywhere near real infrastructure, conditional expressions for picking between two values, and dynamic blocks for generating a variable number of nested blocks inside a single resource.

Built-in functions: transforming and combining values

Section titled “Built-in functions: transforming and combining values”

Terraform’s function library is organized loosely by the kind of value it operates on. String functions like join and split move between a list and a delimited string, and format builds a string from a template plus arguments. Collection functions like lookup, merge, and contains read from and combine maps and lists. Numeric functions cover the usual arithmetic and rounding needs. Networking functions like cidrsubnet are specific to carving address ranges, which comes up constantly when working with an Azure virtual network.

variable "vnet_address_space" {
type = string
description = "CIDR block for the virtual network"
default = "10.0.0.0/16"
}
locals {
# cidrsubnet(prefix, newbits, netnum) carves a /16 into /24 subnets
subnet_web_cidr = cidrsubnet(var.vnet_address_space, 8, 0) # 10.0.0.0/24
subnet_app_cidr = cidrsubnet(var.vnet_address_space, 8, 1) # 10.0.1.0/24
subnet_db_cidr = cidrsubnet(var.vnet_address_space, 8, 2) # 10.0.2.0/24
# format() builds a name from a template; join() flattens a list into a string
name_prefix = format("%s-%s", var.environment, var.project)
dns_servers = join(",", ["10.0.0.4", "10.0.0.5"])
# merge() combines maps, later arguments winning on key conflicts
vm_tags = merge(
{ Project = var.project, Environment = var.environment },
{ Tier = "web" },
)
# lookup() reads a map key with a fallback instead of erroring on a miss
region_short_name = lookup(
{ eastus = "eus", eastus2 = "eus2", westeurope = "weu" },
var.location,
"unk",
)
# contains() checks membership without writing a for expression
is_allowed_region = contains(["eastus", "eastus2", "westeurope"], var.location)
}
resource "azurerm_virtual_network" "main" {
name = "${local.name_prefix}-vnet"
address_space = [var.vnet_address_space]
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
}
resource "azurerm_subnet" "web" {
name = "${local.name_prefix}-web-subnet"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = [local.subnet_web_cidr]
}

cidrsubnet is worth sitting with: it takes a base CIDR block, a number of additional bits to extend the prefix by, and a subnet number, and returns a new, non-overlapping CIDR block. cidrsubnet("10.0.0.0/16", 8, 0) extends the /16 by 8 bits to a /24 and picks the zeroth block, giving 10.0.0.0/24; passing 1 for the third argument gives 10.0.1.0/24, and so on. Computing three subnet ranges this way, once, in locals, means every azurerm_subnet resource references a named local instead of a hand-typed CIDR literal that someone has to double-check for overlaps.

terraform console: checking an expression before it goes into a resource

Section titled “terraform console: checking an expression before it goes into a resource”

terraform console opens an interactive REPL against the current configuration and state, without changing either. It is the fastest way to answer “what does this expression actually evaluate to” before pasting it into a resource argument where a typo is much more expensive to notice.

Terminal window
$ terraform console
> join("-", ["dev", "payments", "web"])
"dev-payments-web"
> cidrsubnet("10.0.0.0/16", 8, 1)
"10.0.1.0/24"
> contains(["eastus", "eastus2", "westeurope"], "westeurope")
true
> var.vnet_address_space
"10.0.0.0/16"
> exit

Every line typed at the > prompt is evaluated once and printed, exactly like a plan-time expression would resolve, using the real values already in your variables and state. There is no apply step and no side effect — exit or Ctrl-D just closes the session. This is the tool to reach for whenever a cidrsubnet call or a merge of two maps does not look right in a plan diff and you want to isolate the exact expression before touching the resource block itself.

Some expressions are only valid sometimes — an optional object attribute that may or may not be set. try(expr1, expr2, ...) evaluates each argument in order and returns the first one that does not raise an error; can(expr) evaluates one expression and returns true/false for whether it succeeded, discarding the actual value. Both exist so a single bad reference does not abort the whole plan.

variable "config" {
type = object({
name = string
})
default = {
name = "app"
}
}

Real terraform console output — var.config above has no location attribute at all:

> try(var.config.location, "eastus")
"eastus"
> try(var.config.location)
╷
│ Error: Error in function call
│
│ Call to function "try" failed: no expression succeeded:
│ - Unsupported attribute
│ This object does not have an attribute named "location".
│
│ At least one expression must produce a successful result.
╵
> can(var.config.location)
false
> can(var.config.name)
true

try() with a fallback returns the fallback the moment the first expression errors, with no error surfaced at all. try() with no working expression re-raises a real error — it is not a silent null. can() never returns the value itself, only whether evaluating it would have succeeded — useful inside a validation condition, since a condition must always evaluate to a plain boolean, never raise.

templatefile: rendering an external template file

Section titled “templatefile: rendering an external template file”

templatefile(path, vars) reads a file from disk, renders it as a Terraform template, and returns the result as a string — the standard way to keep templated text (a cloud-init script, an nginx config) out of the HCL file entirely.

config.tftpl
server {
name = "${name}"
port = ${port}
%{ for tag in tags ~}
tag "${tag}"
%{ endfor ~}
}
locals {
rendered_config = templatefile("${path.module}/config.tftpl", {
name = "web"
port = 8080
tags = ["prod", "eastus"]
})
}

Real output from terraform console against the exact template above:

> templatefile("${path.module}/config.tftpl", { name = "web", port = 8080, tags = ["prod", "eastus"] })
<<EOT
server {
name = "web"
port = 8080
tag "prod"
tag "eastus"
}
EOT

The %{ for tag in tags ~} / %{ endfor ~} pair is a template directive, not HCL — it runs once per element of tags, and the trailing ~ on each strips the newline that would otherwise appear around the directive line, keeping the rendered output free of stray blank lines. Every variable the template references must be supplied — an omitted one is a real error at plan time, not a silently empty string.

Provider-defined functions (Terraform 1.8+)

Section titled “Provider-defined functions (Terraform 1.8+)”

Terraform 1.8’s changelog is explicit: “Providers can now offer functions which can be used from within the Terraform configuration language,” called with the syntax provider::provider_name::function_name(...). Unlike the built-in standard library above, these functions are implemented by a specific provider, ship with it, and exist because that provider’s own data (an Azure resource ID) needs parsing logic a general-purpose function library has no business knowing about.

The hashicorp/azurerm provider (confirmed by reading internal/provider/function/ in the hashicorp/terraform-provider-azurerm source) currently ships two: parse_resource_id and normalise_resource_id.

locals {
parsed_id = provider::azurerm::parse_resource_id(azurerm_linux_virtual_machine.web.id)
}

Real terraform console output against a real Azure resource ID shape:

> provider::azurerm::parse_resource_id("/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-demo/providers/Microsoft.Compute/virtualMachines/web")
{
"full_resource_type" = "Microsoft.Compute/virtualMachines"
"parent_resources" = tomap({})
"resource_group_name" = "rg-demo"
"resource_name" = "web"
"resource_provider" = "Microsoft.Compute"
"resource_scope" = ""
"resource_type" = "virtualMachines"
"subscription_id" = "00000000-0000-0000-0000-000000000000"
}
> provider::azurerm::normalise_resource_id("/subscriptions/00000000-0000-0000-0000-000000000000/resourcegroups/rg-demo/providers/microsoft.compute/virtualmachines/web")
"/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-demo/providers/Microsoft.Compute/virtualMachines/web"

parse_resource_id turns the long, easy-to-mistype ARM resource ID string every azurerm_* resource exposes as .id into a structured object — resource_group_name, resource_type, and subscription_id without a hand-rolled split("/", ...). normalise_resource_id is the fix for the opposite, more subtle problem: two Azure resource ID strings that refer to the exact same resource can differ in casing (resourcegroups vs resourceGroups, microsoft.compute vs Microsoft.Compute) depending on which API returned them, which breaks a naive string comparison; normalise_resource_id rewrites either casing to ARM’s canonical form so two IDs for the same resource compare equal.

Conditional expressions: choosing between two values

Section titled “Conditional expressions: choosing between two values”

A conditional expression has three parts: a condition that must evaluate to true or false, a value to use when it is true, and a value to use when it is false, written as condition ? true_val : false_val. It is Terraform’s only branching construct at the value level — there is no if statement, only an expression that resolves to one of two values.

locals {
vm_size = var.environment == "prod" ? "Standard_D8s_v3" : "Standard_D2s_v3"
vm_tier_tag = upper(var.environment == "prod" ? "critical" : "standard")
}
resource "azurerm_linux_virtual_machine" "web" {
name = "${local.name_prefix}-web"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
size = local.vm_size
admin_username = "azureuser"
admin_ssh_key {
username = "azureuser"
public_key = file("~/.ssh/id_rsa.pub")
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts"
version = "latest"
}
tags = {
Tier = local.vm_tier_tag
}
}

local.vm_size picks a bigger virtual machine size only in prod, everywhere else falling back to a smaller default — a one-line replacement for what would otherwise be two nearly-identical variable defaults or a lookup table for a binary choice. local.vm_tier_tag chains a conditional straight into a function call: the conditional resolves to "critical" or "standard" first, and upper() then transforms whichever string won before it is assigned to the tag. Nesting like this is normal — a conditional is just an expression, and it can appear anywhere an expression is valid, including as an argument to another function.

dynamic blocks: a variable number of nested blocks inside one resource

Section titled “dynamic blocks: a variable number of nested blocks inside one resource”

count and for_each on a resource (covered in the next lesson) decide how many copies of an entire resource to create. A dynamic block solves a related but distinct problem: generating a variable number of repeated nested blocks inside a single resource, driven by a collection. azurerm_network_security_group is a good example — it takes zero or more security_rule blocks, and the number of rules usually comes from a list the caller controls, not a fixed count known in advance.

variable "allowed_ports" {
type = list(number)
description = "TCP ports allowed inbound on the network security group"
default = [22, 443, 8080]
}
resource "azurerm_network_security_group" "web" {
name = "${local.name_prefix}-web-nsg"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
dynamic "security_rule" {
for_each = var.allowed_ports
content {
name = "allow-port-${security_rule.value}"
priority = 100 + security_rule.key
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = tostring(security_rule.value)
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
}

The dynamic "security_rule" block iterates var.allowed_ports and emits one security_rule nested block per element, without you writing out security_rule { ... } three separate times or updating the resource every time the port list changes. Inside content, security_rule.value is the current list element and security_rule.key is its index, both named after the block label by default. Add a fourth port to allowed_ports and a fourth security_rule block appears at the next plan, with no change to the resource block itself — this is the same convenience count/for_each bring at the whole-resource level, applied one level deeper, to a block that lives inside a single resource.

flowchart LR
  a["var.environment"] --> b{"conditional: environment == prod ?"}
  b -->|true| c["critical"]
  b -->|false| d["standard"]
  c --> e["upper() function call"]
  d --> e
  e --> f["local.vm_tier_tag"]
  f --> g["azurerm_linux_virtual_machine.web tags"]
A conditional and a function call resolve to one value used in a resource argument

Every construct in this lesson is really about resolving a value before Terraform ever calls a provider: a function call, try/can, a conditional, templatefile, and a provider-defined function all run during expression evaluation, strictly earlier than any plan-time API interaction. dynamic is the one exception that looks like the others but is not — it does not compute a value, it expands into HCL structure (repeated nested blocks) before the resource’s own schema is even evaluated.

ChoiceBenefitCost
try(a, b, ...)A single bad reference doesn’t abort the whole plan — proven above with a real fallbackSilently swallows the reason the first expression failed unless a later argument also fails
can(expr)A clean boolean for a validation condition, proven aboveDiscards the actual value entirely
templatefileKeeps large templated text out of HCL strings entirely, proven above with a real renderEvery referenced variable must be supplied — a missing one is a real plan-time error
Provider-defined function (provider::...)Azure resource-ID parsing/normalizing without hand-rolled split() and casing-fragile string comparisons, proven aboveTied to the installed azurerm provider version, not just the Terraform CLI version
dynamic blockGenerates a variable number of nested blocks from a collection, proven above with security_ruleAdds indirection (security_rule.value/.key) that makes a resource harder to read at a glance than a small fixed set of literal blocks
  • “try() silently returns null if every expression fails.” Proven above: it re-raises a real error (“no expression succeeded”) — try() only suppresses the error when some argument actually succeeds.
  • “can() returns the value if the expression succeeds.” Proven above: can() always returns a plain boolean, never the value.
  • “Two Azure resource ID strings that look different are definitely different resources.” Not necessarily — proven above with normalise_resource_id: casing differences (resourcegroups vs resourceGroups) can make the same resource’s ID compare unequal to itself as a plain string; normalize before comparing.
  • “Only AWS has provider-defined functions.” Contradicted above by reading both providers’ source directly — hashicorp/google ships seven of its own (name_from_id, region_from_zone, and more), and hashicorp/aws ships four.
  • “dynamic and for_each on a resource solve the same problem.” They don’t: for_each on a resource creates multiple whole resource instances; dynamic creates multiple nested blocks inside one resource instance — proven above with a single azurerm_network_security_group.web containing three generated security_rule blocks.
  • A generated try(a.b.c, "default") used everywhere a value might be missing, without checking whether a missing value should actually be a loud plan-time error instead.
  • Manual split("/", azurerm_resource.thing.id) used to pull the resource group or resource type out of an Azure resource ID, when provider::azurerm::parse_resource_id already does exactly that, proven above — and more reliably, since ARM ID casing is not fully consistent across APIs.
  • A raw string == comparison between two Azure resource IDs pulled from different sources (one from a resource attribute, one from a data source or variable) without normalizing casing first — proven above, this is a real source of false “not equal” results.
  • A templatefile() call missing a variable the template actually references — proven above, this is a real plan-time error, not a blank substitution.
  • A dynamic block generated for a fixed, small, known set of nested blocks (two or three security_rule entries that never change) — indirection with no benefit when the set isn’t actually variable.

🐛 Spot the bug

variable "location_override" {
type = string
default = null
}
locals {
# "Safely" fall back to a default location
effective_location = try(var.location_override.name, "eastus")
}
Show the answer

This is worse than an error — it is a silent bug. var.location_override is typed as a plain string (or null), not an object, so it never has a .name attribute — var.location_override.name fails every single time, for every possible value of var.location_override, including a real override a caller actually set. Because it always fails, try()’s fallback "eastus" fires every single time too, with no error ever surfacing. Proven with the identical shape in terraform console, setting a real override:

$ TF_VAR_location_override="westeurope" terraform console
> try(var.location_override.name, "eastus")
"eastus"

A caller who set location_override = "westeurope" gets "eastus" back anyway — their override is silently discarded, with no error, no warning, nothing in the plan output to suggest anything went wrong. Proven with the raw expression (no try()) against the same variable, this is what try() is quietly swallowing:

$ terraform console
> var.location_override.name
╷
│ Error: Attempt to get attribute from null value
│
│ This value is null, so it does not have any attributes.
╵

The fix is to stop treating try() as a substitute for getting the variable’s type right: reference var.location_override directly (try(var.location_override, "eastus"), no .name) since it is a plain string, or change the variable’s type to object({ name = string }) if callers are genuinely meant to pass a structured value.

What is terraform console used for
What are the three parts of a conditional expression written as condition ? true_val : false_val
When should you reach for a dynamic block instead of count or for_each on the whole resource
What does cidrsubnet(var.vnet_address_space, 8, 1) do
What happens when every expression passed to try() fails?
What does provider::azurerm::normalise_resource_id fix?
Which Terraform version introduced provider-defined functions (the provider::name::function() syntax)?
Does the google provider ship any provider-defined functions?