Expressions, Functions, and Conditionals
The idea in one sentence
Section titled “The idea in one sentence”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.
$ 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"> exitEvery 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.
try and can: expressions that might fail
Section titled “try and can: expressions that might fail”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)truetry() 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.
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"] })<<EOTserver { name = "web" port = 8080 tag "prod" tag "eastus"}
EOTThe %{ 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"] Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
try(a, b, ...) | A single bad reference doesn’t abort the whole plan — proven above with a real fallback | Silently swallows the reason the first expression failed unless a later argument also fails |
can(expr) | A clean boolean for a validation condition, proven above | Discards the actual value entirely |
templatefile | Keeps large templated text out of HCL strings entirely, proven above with a real render | Every 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 above | Tied to the installed azurerm provider version, not just the Terraform CLI version |
dynamic block | Generates a variable number of nested blocks from a collection, proven above with security_rule | Adds indirection (security_rule.value/.key) that makes a resource harder to read at a glance than a small fixed set of literal blocks |
Common Misconceptions
Section titled “Common Misconceptions”- “
try()silently returnsnullif 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 (resourcegroupsvsresourceGroups) 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/googleships seven of its own (name_from_id,region_from_zone, and more), andhashicorp/awsships four. - “
dynamicandfor_eachon a resource solve the same problem.” They don’t:for_eachon a resource creates multiple whole resource instances;dynamiccreates multiple nested blocks inside one resource instance — proven above with a singleazurerm_network_security_group.webcontaining three generatedsecurity_ruleblocks.
Reading AI-generated code
Section titled “Reading AI-generated code”- 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, whenprovider::azurerm::parse_resource_idalready 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
dynamicblock generated for a fixed, small, known set of nested blocks (two or threesecurity_ruleentries that never change) — indirection with no benefit when the set isn’t actually variable.
🐛 Spot the bug
variable "location_override" {type = stringdefault = null}
locals {# "Safely" fall back to a default locationeffective_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.