Skip to content

Expressions, Functions, and Conditionals

Terraform configuration is not just static values — built-in functions transform data, conditional expressions choose between two values, and dynamic blocks generate a variable number of nested blocks, all evaluated at plan time before any GCP API call happens.

Terraform ships a fixed set of built-in functions — there is no way to define your own — grouped loosely by the kind of data they operate on. A few that come up constantly in GCP configurations:

locals {
# String functions
bucket_suffix = join("-", ["assets", "prod", "us"])
path_parts = split("/", "projects/my-project/zones/us-central1-a")
log_message = format("instance %s in zone %s", "web-1", "us-central1-a")
# Collection functions
tier_lookup = lookup({ dev = "db-f1-micro", prod = "db-custom-2-7680" }, "dev", "db-f1-micro")
merged_labels = merge(
{ managed_by = "terraform" },
{ environment = "prod" },
)
has_ssh = contains([22, 443, 8080], 22)
# Networking function: carve a /24 out of a /16 VPC range
subnet_a = cidrsubnet("10.0.0.0/16", 8, 0)
subnet_b = cidrsubnet("10.0.0.0/16", 8, 1)
}

join and split are inverses of each other — one turns a list into a delimited string, the other turns a delimited string back into a list. lookup reads a map by key with an explicit fallback as its third argument, so a missing key never causes an error. merge combines multiple maps into one, with later arguments overriding keys from earlier ones. contains checks list membership and returns a plain boolean. cidrsubnet(prefix, newbits, netnum) is the one worth knowing cold for GCP networking: it takes a base CIDR block, extends its prefix length by newbits, and returns the netnum-th subnet of that new size — exactly the tool for carving a VPC network’s 10.0.0.0/16 range into a series of /24 subnets without hand-calculating addresses.

terraform console — a REPL for expressions

Section titled “terraform console — a REPL for expressions”

Before putting an unfamiliar function or expression into a real resource argument, terraform console opens an interactive REPL that evaluates expressions against the current configuration and state, without planning or applying anything.

Terminal window
terraform console
> cidrsubnet("10.0.0.0/16", 8, 1)
"10.0.1.0/24"
> join("-", ["assets", "prod", "us"])
"assets-prod-us"
> var.environment
"dev"

This is the fastest way to answer “what does this expression actually evaluate to” — type it in, read the result, and only then paste the working expression into a .tf file. It is read-only against real infrastructure: terraform console can read variables, locals, and existing state, but it cannot create, change, or destroy anything.

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 zone attribute at all:

> try(var.config.zone, "us-central1-a")
"us-central1-a"
> try(var.config.zone)
╷
│ Error: Error in function call
│
│ Call to function "try" failed: no expression succeeded:
│ - Unsupported attribute
│ This object does not have an attribute named "zone".
│
│ At least one expression must produce a successful result.
╵
> can(var.config.zone)
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 startup 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", "us-central1"]
})
}

Real output from terraform console against the exact template above:

> templatefile("${path.module}/config.tftpl", { name = "web", port = 8080, tags = ["prod", "us-central1"] })
<<EOT
server {
name = "web"
port = 8080
tag "prod"
tag "us-central1"
}
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. templatefile is the standard way to build a google_compute_instance metadata_startup_script from a real file instead of an inline 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 (a GCP self-link or resource ID) needs parsing logic a general-purpose function library has no business knowing about.

The hashicorp/google provider (confirmed by listing google/functions/ in the hashicorp/terraform-provider-google source) currently ships seven: name_from_id, region_from_zone, region_from_id, zone_from_id, project_from_id, location_from_id, and element_from_id.

locals {
instance_zone = provider::google::zone_from_id(google_compute_instance.web.id)
}

Real terraform console output against a real GCP resource id/self-link shape:

> provider::google::name_from_id("projects/my-project/zones/us-central1-c/instances/my-instance")
"my-instance"
> provider::google::region_from_zone("us-central1-c")
"us-central1"
> provider::google::project_from_id("projects/my-project/zones/us-central1-c/instances/my-instance")
"my-project"

name_from_id pulls just the final path segment (the resource’s short name) out of any of the id/URI/self-link shapes GCP resources return — projects/my-project/zones/us-central1-c/instances/my-instance, a full https://www.googleapis.com/compute/v1/... self-link, or a partial //service.googleapis.com/... name all work, per the function’s own documented parameter description. region_from_zone turns a zone like us-central1-c into its region us-central1 without a hand-rolled regexall/join on the zone string, which is exactly the kind of string surgery a general-purpose function library should not have to special-case for one provider’s naming convention.

A conditional expression has the shape condition ? true_val : false_val, and it evaluates to exactly one of the two branches based on whether condition is true or false.

variable "environment" {
type = string
description = "Deployment environment name"
default = "dev"
}
locals {
machine_type = var.environment == "prod" ? "e2-standard-4" : "e2-medium"
}
resource "google_compute_instance" "web" {
name = "web-server"
machine_type = local.machine_type
zone = "us-central1-a"
boot_disk {
initialize_params {
image = "debian-cloud/debian-12"
}
}
network_interface {
network = "default"
access_config {}
}
}

The three parts are condition (an expression that must evaluate to a boolean), true_val (used when condition is true), and false_val (used when condition is false). Here, local.machine_type resolves to "e2-standard-4" only when var.environment is exactly "prod", and falls back to "e2-medium" for every other value, including "dev" and "staging".

dynamic blocks — a variable number of nested blocks

Section titled “dynamic blocks — a variable number of nested blocks”

A count or for_each on a resource generates a variable number of whole resources, covered in the next lesson. A dynamic block solves a related but distinct problem: generating a variable number of repeated nested blocks inside a single resource.

variable "allowed_ports" {
type = list(number)
description = "TCP ports allowed inbound on the firewall rule"
default = [22, 80, 443]
}
resource "google_compute_firewall" "allow_multiple" {
name = "allow-multiple-ports"
network = "default"
dynamic "allow" {
for_each = var.allowed_ports
content {
protocol = "tcp"
ports = [allow.value]
}
}
}

The dynamic "allow" block iterates over var.allowed_ports, and for each element generates one allow { ... } nested block inside the single google_compute_firewall.allow_multiple resource. Inside content, allow.value refers to the current element — the iterator variable defaults to the dynamic block’s own label, allow. Reach for dynamic when the thing that repeats is a nested block inside one resource; reach for count or for_each on the resource itself when the thing that repeats is the whole resource.

flowchart LR
  a["var.environment"] --> c{"environment == prod ?"}
  c -->|true| t["e2-standard-4"]
  c -->|false| f["e2-medium"]
  t --> m["local.machine_type"]
  f --> m
  m --> r["google_compute_instance.web.machine_type"]
A conditional expression and a function call resolve to one final 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::...)GCP self-link/id parsing without hand-rolled split()/regexall(), proven aboveTied to the installed google provider version, not just the Terraform CLI version
dynamic blockGenerates a variable number of nested blocks from a collection, proven above with allowAdds indirection (allow.value) 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.
  • “provider::google::name_from_id only accepts the short projects/.../instances/name id format.” Not confirmed by the function’s own documented parameters — it also accepts a full https://www.googleapis.com/... self-link and a partial //service.googleapis.com/... name, per the provider source’s own description.
  • “Only AWS has provider-defined functions.” Contradicted above by reading both providers’ source directly — hashicorp/azurerm ships two of its own (parse_resource_id, normalise_resource_id), 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 google_compute_firewall.allow_multiple containing three generated allow 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("/", google_compute_instance.web.id) or a hand-rolled regexall() used to pull the zone, region, or short name out of a GCP resource id/self-link, when a provider-defined function (provider::google::zone_from_id, region_from_zone, name_from_id) already does exactly that, 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.
  • provider:: function syntax appearing in a generated example with no version check against the actual google provider version pinned in required_providers — availability is per-provider-version, not just “Terraform 1.8 or later.”
  • A dynamic block generated for a fixed, small, known set of nested blocks (two or three allow entries that never change) — indirection with no benefit when the set isn’t actually variable.
  • A generated google_compute_instance with no network_interface block at all — this is not optional; terraform validate rejects it because network_interface is a required block.

🐛 Spot the bug

variable "zone_override" {
type = string
default = null
}
locals {
# "Safely" fall back to a default zone
effective_zone = try(var.zone_override.name, "us-central1-a")
}
Show the answer

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

$ TF_VAR_zone_override="europe-west1-b" terraform console
> try(var.zone_override.name, "us-central1-a")
"us-central1-a"

A caller who set zone_override = "europe-west1-b" gets "us-central1-a" 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.zone_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.zone_override directly (try(var.zone_override, "us-central1-a"), 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
When would 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 does provider::google::region_from_zone("us-central1-c") return?
Which Terraform version introduced provider-defined functions (the provider::name::function() syntax)?
Does the azurerm provider ship any provider-defined functions?