Skip to content

Expressions, Functions, and Conditionals

Terraform configuration is not just static values — expressions call built-in functions, branch with a conditional, and generate a variable number of nested blocks, all evaluated before any resource is ever created.

Built-in functions, and terraform console for checking them

Section titled “Built-in functions, and terraform console for checking them”

Terraform ships a large standard library of built-in functions — there is no way to define your own. They fall into families worth knowing by category rather than memorizing individually: string functions like join, split, and format reshape text; collection functions like lookup, merge, and contains work on lists, maps, and sets; numeric functions handle arithmetic and rounding; and a set of networking-specific functions, most importantly cidrsubnet, exist because carving up IP address ranges by hand is exactly the kind of repetitive, error-prone arithmetic a function should do instead.

locals {
# string functions
bucket_name = join("-", [var.project, var.environment, "logs"])
az_list = split(",", "us-east-1a,us-east-1b,us-east-1c")
label = format("%s-%03d", var.project, var.build_number)
# collection functions
instance_type = lookup(var.instance_type_overrides, var.environment, "t3.micro")
full_tags = merge(var.default_tags, var.extra_tags)
is_prod_like = contains(["prod", "staging"], var.environment)
}
resource "aws_subnet" "app" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 4, count.index)
}

cidrsubnet(prefix, newbits, netnum) takes a parent CIDR block, extends its prefix length by newbits, and fills the new bits with netnum to produce one subnet. Given a /16 VPC and newbits = 4, each call produces a /20, and passing count.index as netnum hands out consecutive, non-overlapping subnets automatically — no one has to compute 10.0.16.0/20, 10.0.32.0/20, and so on by hand, and no one has to remember to skip a range that is already in use.

Before putting an unfamiliar function into a resource argument, terraform console is the fastest way to find out what it actually returns. It opens an interactive REPL against the current configuration and state, without touching any real infrastructure.

Terminal window
terraform console
> cidrsubnet("10.0.0.0/16", 4, 2)
"10.0.32.0/20"
> join("-", ["payments", "prod", "logs"])
"payments-prod-logs"
> lookup({dev = "t3.micro", prod = "m6i.large"}, "staging", "t3.small")
"t3.small"

Every one of those answers is something you would otherwise have had to guess at, run a full plan to see, or work out on paper. terraform console evaluates any expression — a function call, a reference to a variable or resource attribute, a whole conditional — and prints the result immediately, which makes it the natural first stop whenever an expression’s behavior is not obvious.

Some expressions are only valid sometimes — an optional object attribute that may or may not be set, an index into a list that might be shorter than expected. 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 specifically 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 region attribute at all:

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

Three things worth noticing: try() with a fallback returns the fallback the moment the first expression errors — proven above, "us-east-1" came back with no error surfaced at all. try() with no working expression re-raises a real error — it is not a silent null. And can() never returns the value itself, only whether evaluating it would have succeeded — useful inside a validation condition (the input-variables lesson’s can(regex(...)) pattern) precisely because 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 (the same ${...} interpolation and %{ for ... }/%{ if ... } directives available inline in a string), and returns the rendered result as a string — the standard way to keep a large chunk of templated text (a cloud-init script, an nginx config, a systemd unit) 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", "east"]
})
}

Real output from terraform console against the exact template above:

> templatefile("${path.module}/config.tftpl", { name = "web", port = 8080, tags = ["prod", "east"] })
<<EOT
server {
name = "web"
port = 8080
tag "prod"
tag "east"
}
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 itself, keeping the rendered output free of stray blank lines. templatefile’s second argument must supply a value for every variable the template references — an omitted one is a real error at plan time, not a silently empty string.

A conditional expression has exactly three parts: condition ? true_val : false_val. Terraform evaluates condition to a boolean and returns true_val if it is true, false_val otherwise — nothing more exotic than that, but combined with a function call it becomes a compact way to pick between two computed values.

variable "environment" {
type = string
default = "dev"
}
locals {
instance_type = var.environment == "prod" ? "m6i.large" : "t3.micro"
backup_retention_days = var.environment == "prod" ? 30 : 7
}
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = local.instance_type
}

local.instance_type resolves to exactly one of the two strings before aws_instance.web is ever planned — there is no branching left at apply time, only a single, already-resolved value sitting in instance_type. This is the pattern to reach for whenever a value depends on a single yes/no condition; for anything with more than two outcomes, lookup against a map (as shown above) or a for expression is usually clearer than nesting conditionals inside each other.

Dynamic blocks: a variable number of nested blocks

Section titled “Dynamic blocks: a variable number of nested blocks”

count and for_each on a resource, covered in the next lesson, create a variable number of whole resources. A dynamic block solves a different, narrower problem: generating a variable number of repeated nested blocks inside a single resource, when the number of those nested blocks depends on a collection whose size is not known until the configuration runs.

variable "allowed_ports" {
type = list(number)
description = "TCP ports allowed inbound on the security group"
default = [22, 443, 8080]
}
resource "aws_security_group" "app" {
name = "app-sg"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.allowed_ports
content {
from_port = ingress.value
to_port = ingress.value
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
}

There is exactly one aws_security_group.app resource, but the dynamic "ingress" block expands into one ingress { ... } block per entry in var.allowed_ports — three ports in, three ingress blocks out, with no dynamic block at all giving zero ingress blocks. Inside content, ingress.value refers to the current element, the same role each.value plays for a resource-level for_each. Reach for dynamic specifically when the repeated thing lives inside a resource, like ingress inside aws_security_group; reach for count or for_each on the resource itself when the repeated thing is the whole resource.

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 covered above, these functions are implemented by a specific provider, ship with it, and exist because that provider’s own data (an ARN, a resource ID) needs parsing logic a general-purpose function library has no business knowing about.

The hashicorp/aws provider (confirmed by reading internal/function/ in the hashicorp/terraform-provider-aws source) currently ships four: arn_parse, arn_build, trim_iam_role_path, and user_agent.

locals {
parsed_arn = provider::aws::arn_parse("arn:aws:iam::444455556666:role/example")
}

Real terraform console output:

> provider::aws::arn_parse("arn:aws:iam::444455556666:role/example")
{
"account_id" = "444455556666"
"partition" = "aws"
"region" = ""
"resource" = "role/example"
"service" = "iam"
}
> provider::aws::arn_build("aws", "iam", "", "444455556666", "role/example")
"arn:aws:iam::444455556666:role/example"
> provider::aws::trim_iam_role_path("arn:aws:iam::444455556666:role/some/deep/path/example")
"arn:aws:iam::444455556666:role/example"

arn_parse turns a string every AWS resource returns (an ARN) into a structured object without a hand-rolled regex() and split() combination; arn_build is its inverse, useful when a resource argument needs a full ARN assembled from parts you already have as separate variables. Before reaching for a provider-defined function, check what the installed provider actually ships — this is real, versioned code from the provider binary, not part of Terraform core, so its availability depends on the provider version pinned in required_providers, not the Terraform CLI version alone (1.8+ is necessary but not sufficient).

flowchart LR
  e["var.environment"] --> c{"environment == prod ?"}
  c -->|true| a["m6i.large"]
  c -->|false| b["t3.micro"]
  a --> r["local.instance_type"]
  b --> r
  r --> res["aws_instance.web"]
A conditional and a function call resolve to one value before a resource is planned

Every construct in this lesson is really about resolving a value before Terraform ever calls a provider: a function call, a try/can, a conditional, a 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. Keeping “value-producing expression” and “structure-generating meta-construct” as separate categories explains why try/can are useful inside a validation condition (which needs a boolean value) while dynamic never could be.

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; overusing it can hide a real config bug behind a fallback
can(expr)A clean boolean for a validation condition, proven above (false/true on the same object)Discards the actual value entirely — never useful when you need the value itself, only whether it would succeed
templatefileKeeps large templated text (cloud-init, config files) out of HCL strings entirely, proven above with a real renderEvery variable the template references must be supplied — a missing one is a real plan-time error, not a blank
Provider-defined function (provider::...)Provider-specific parsing logic (ARN structure, resource ID shape) without hand-rolled regex()Tied to the installed provider version, not just the Terraform CLI version — proven above, four are real in this course’s pinned hashicorp/aws ~> 6.0
dynamic blockGenerates a variable number of nested blocks from a collection, proven above with ingressAdds a layer of indirection (ingress.value instead of a literal block) 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 — can(var.config.name) returned true, not "app".
  • “Provider-defined functions are a Terraform core feature, so every provider version supports them once you’re on Terraform 1.8+.” Proven above by checking the exact provider source: they’re implemented and shipped by the provider, so availability depends on the provider version, not the Terraform CLI version alone.
  • “Only AWS has provider-defined functions; azurerm and google don’t.” Contradicted above by checking both providers’ source directly — both ship real ones (parse_resource_id/normalise_resource_id for azurerm, seven *_from_id/region_from_zone functions for google).
  • “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 aws_security_group.app containing three generated ingress 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 — try() is a deliberate suppression, not a default reach-for-everything pattern.
  • regex() and manual string splitting used to pull fields out of an ARN or a cloud resource ID, when a provider-defined function (provider::aws::arn_parse, provider::azurerm::parse_resource_id, provider::google::name_from_id) already does exactly that, more reliably, proven above.
  • A templatefile() call missing a variable the template actually references — proven above, this is a real plan-time error, not a blank substitution; check the template file’s own ${...} references match the vars map.
  • provider:: function syntax appearing in a generated example with no version check against the actual provider version pinned in required_providers — proven above, availability is per-provider-version, not just “Terraform 1.8 or later.”
  • A dynamic block generated for something that is actually a fixed, small, known set of nested blocks (two or three ingress rules that never change) — a dynamic block adds indirection for no benefit when the set isn’t actually variable; plain repeated literal blocks read more clearly.

🐛 Spot the bug

variable "region_override" {
type = string
default = null
}
locals {
# "Safely" fall back to a default region
effective_region = try(var.region_override.name, "us-east-1")
}
Show the answer

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

$ TF_VAR_region_override="eu-west-1" terraform console
> try(var.region_override.name, "us-east-1")
"us-east-1"

A caller who set region_override = "eu-west-1" gets "us-east-1" 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.region_override.name
╷
│ Error: Attempt to get attribute from null value
│
│ This value is null, so it does not have any attributes.
╵

(With a non-null string it fails the same way, just with a different message — string values never have named attributes.) The fix is to stop treating try() as a substitute for getting the variable’s type right: reference var.region_override directly (try(var.region_override, "us-east-1"), 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 primarily used for?
What are the three parts of a conditional expression?
When should you reach for a dynamic block instead of count or for_each on a resource?
What does cidrsubnet(prefix, newbits, netnum) do?
What happens when every expression passed to try() fails?
What is the key difference between try() and can()?
Which Terraform version introduced provider-defined functions (the provider::name::function() syntax)?
Does the azurerm provider ship any provider-defined functions?