Functions, Dynamic Blocks and Advanced Expressions
Terraform expressions become much more useful once you stop thinking of them as isolated values and start using them to transform real configuration data. Functions, for expressions, conditionals, and dynamic blocks let you turn structured input into infrastructure configuration without duplicating blocks everywhere.
The goal is not to memorize hundreds of functions. It is to learn a small set of patterns that make real Terraform configurations easier to build.
Functions: Transforming Values
Terraform provides functions for working with strings, collections, numbers, maps, and other values.
length
variable "servers" {
type = list(string)
default = [
"frontend",
"backend",
"worker"
]
}
output "server_count" {
value = length(var.servers)
}The length function answers a simple question:
How many elements are in this collection?Other useful functions solve slightly more interesting problems.
lookup
Use lookup when you want to retrieve a value from a map and provide a fallback:
locals {
sizes = {
frontend = "small"
backend = "medium"
}
worker_size = lookup(local.sizes, "worker", "small")
}The syntax is:
lookup(<map>, <key>, <default_value_if_key_doesnot_exists>)The result is:
smallbecause worker is not present in the map.
merge
merge combines maps:
locals {
common_tags = {
environment = "dev"
}
application_tags = {
application = "web"
}
tags = merge(local.common_tags, local.application_tags)
}
output "tags" {
value = local.tags
}The result is conceptually:
tags = {
"application" = "web"
"environment" = "dev"
}This is useful when you have common values plus resource-specific values.
concat
concat joins lists:
locals {
public_subnets = ["public-a", "public-b"]
private_subnets = ["private-a", "private-b"]
all_subnets = concat(
local.public_subnets,
local.private_subnets
)
}
output "subnets" {
value = local.all_subnets
}The resulting list contains all four elements:
subnets = [
"public-a",
"public-b",
"private-a",
"private-b",
]flatten
flatten turns nested lists into a single list:
locals {
groups = [
["frontend", "backend"],
["worker"]
]
servers = flatten(local.groups)
}Conceptually:
[
["frontend", "backend"],
["worker"]
]
β flatten
["frontend", "backend", "worker"]contains
Use contains to test whether a collection contains a value:
locals {
environments = ["dev", "stage", "prod"]
is_production = contains(local.environments, "prod")
}The result is a boolean.
try, can, and coalesce
These three functions are particularly useful when expressions may involve optional or uncertain values.
try
try evaluates expressions and returns the first one that succeeds:
locals {
name = try(var.settings.name, "default-name")
}This can be useful when a nested value (settings.name) may not exist.
This can also be useful when you want specific types of inputs. If you expects a variable to be string or list, you can use:
variable "example" {
type = any
}
locals {
example = try(
[tostring(var.example)],
tolist(var.example),
)
}However, do not use try simply to suppress every possible error. If a value is required, hiding the problem can make debugging harder.
can
can tests whether an expression can be evaluated successfully:
locals {
has_name = can(var.settings.name)
}This produces a boolean.
It is useful when you need to conditionally handle a value whose availability is uncertain.
coalesce
coalesce returns the first non-null value:
locals {
region = coalesce(var.region, var.default_region)
}This is useful for fallback values.
Tip
Functions are most useful when they make the intent of your configuration clearer. If a long expression requires a paragraph to explain what it does, consider breaking it into locals or simplifying the data structure.
for Expressions: Transforming Collections
A for expression lets you transform a collection into another collection.
For example:
variable "servers" {
default = [
"frontend",
"backend",
"worker"
]
}
locals {
upper_names = [
for name in var.servers : upper(name)
]
}Conceptually:
frontend β FRONTEND
backend β BACKEND
worker β WORKERThe expression follows this general pattern:
for item in collection : transformed_valueYou can also create maps:
locals {
server_sizes = {
frontend = "small"
backend = "medium"
worker = "small"
}
names_by_size = {
for name, size in local.server_sizes :
name => size
}
}The power comes from combining iteration with conditions and functions.
Filtering with for
You can add an if condition:
locals {
production_servers = [
for server in var.servers : server
if server.environment == "prod"
]
}The if controls which elements are included.
This is useful when your input contains more objects than a particular resource needs.
For example:
all servers
β
βΌ
filter environment == prod
β
βΌ
production serversNested Expressions
Terraform expressions can be nested.
For example:
locals {
production_names = [
for server in var.servers :
upper(server.name)
if server.environment == "prod"
]
}This combines:
- iteration,
- filtering,
- and transformation.
That is powerful, but readability matters.
A shorter expression is not automatically a better expression.
If an expression becomes difficult to understand, use locals to name intermediate concepts.
Conditional Expressions
Conditional expressions are useful when a value depends on a condition:
locals {
size = var.environment == "prod" ? "large" : "small"
}This reads as:
if production
β large
else
β smallReal configurations often combine conditions with functions:
locals {
replicas = var.environment == "prod"
? max(var.requested_replicas, 3)
: 1
}The important principle is to keep the business rule visible.
Dynamic Blocks
Sometimes a resource contains a nested block that can appear multiple times.
For example, conceptually:
resource "some_provider_server" "web" {
name = "web"
rule {
port = 80
}
rule {
port = 443
}
}If the number of rule blocks should come from data, a dynamic block can generate those nested blocks:
variable "ports" {
type = list(number)
default = [80, 443]
}
resource "some_provider_server" "web" {
name = "web"
dynamic "rule" {
for_each = var.ports
content {
port = rule.value
}
}
}Terraform evaluates the collection and generates one nested rule block per element.
Conceptually:
var.ports
β
βββ 80 βββ rule { port = 80 }
β
βββ 443 ββ rule { port = 443 }Dynamic Block vs Resource for_each
This distinction is important.
A resource-level for_each creates multiple resource instances:
resource "some_provider_server" "web" {
for_each = var.servers
}Terraform then has addresses such as:
some_provider_server.web["frontend"]
some_provider_server.web["backend"]A dynamic block generates repeated nested blocks inside one resource:
resource "some_provider_server" "web" {
dynamic "rule" {
for_each = var.ports
content {
port = rule.value
}
}
}The resource remains one resource instance.
resource
βββ rule
βββ rule
βββ ruleThis is not the same thing as creating three resources.
Note
Use resource for_each when you need multiple resource instances. Use a dynamic block when one resource needs a variable number of nested configuration blocks.
When Dynamic Blocks Help β And When They Hurt
Dynamic blocks are useful when the nested block genuinely needs to be generated from data.
They can become harmful when they make simple configuration difficult to read.
Compare:
rule {
port = 80
}
rule {
port = 443
}with a complicated dynamic expression that generates the same two static blocks.
If the number of blocks is always known and small, explicit blocks may be clearer.
Dynamic configuration is most valuable when the collection itself is variable.
A Practical Pattern
A common pattern is:
Input variables
β
βΌ
locals
β
βββ functions
βββ for expressions
βββ conditionals
β
βΌ
Resource configuration
β
βΌ
dynamic nested blocksFor example, you might receive a map of applications, filter it to production applications, transform it into a collection of rules, and then use those rules to generate nested blocks.
The individual tools are not the goal. The goal is to express infrastructure from structured data without creating an unreadable wall of duplicated configuration.
Keep Expressions Understandable
There is a temptation to prove how much Terraform’s expression language you know by putting everything into one expression.
Avoid that.
This:
locals {
production_names = [
for server in var.servers :
upper(server.name)
if server.environment == "prod"
]
}is understandable.
A huge nested expression combining multiple for expressions, merge, flatten, try, conditionals, and dynamic blocks may technically work but become difficult to maintain.
Terraform configuration is infrastructure code. Someone will eventually need to debug it during an outage or change it six months later.
Tip
Optimize for the next person who has to understand the configuration, not for the fewest lines of HCL.
The final chapter of this section takes these ideas one step further. What happens when infrastructure already exists, or when you want to reorganize your Terraform configuration without making Terraform destroy and recreate everything?