Skip to content

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:

small

because 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   β†’ WORKER

The expression follows this general pattern:

for item in collection : transformed_value

You 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 servers

Nested 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
    β†’ small

Real 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
└── rule

This 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 blocks

For 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?

Last updated on