Terraform expressions and functions: a step-by-step guide
Raw infrastructure code becomes repetitive and brittle without a way to transform data within your configuration. Terraform expressions and functions are the tools that let you compute values, apply logic, and build dynamic infrastructure at plan time. In this guide, we'll walk through the core expression types and functions that senior engineers rely on daily to keep their Terraform maintainable and DRY.
Why expressions and functions matter
When you're managing infrastructure across multiple environments or regions, hardcoding values leads to copy-paste errors and configuration drift. Terraform expressions give us a way to derive values from other parts of your configuration—building tags, naming resources consistently, computing CIDR blocks, or conditionally creating resources based on input variables.
Unlike scripting languages, Terraform evaluates expressions at plan time, not runtime. This means all your data transformations happen before any infrastructure is touched, giving you full visibility and testability.
For expressions: building lists and maps
The for expression is one of the most powerful tools in Terraform. It lets you iterate over collections and transform them into new lists or maps. We use this heavily when dealing with repeating infrastructure patterns—building security group rules, constructing resource tags, or transforming API responses into resource arguments.
Here's a practical scenario: you have a list of application names and need to create an S3 bucket for each one with a consistent naming scheme.
variable "app_names" {
type = list(string)
default = ["api", "web", "worker"]
}
locals {
# Build a map of bucket names from the app list
buckets = {
for app in var.app_names :
app => "${var.environment}-${app}-bucket"
}
}
resource "aws_s3_bucket" "app_buckets" {
for_each = local.buckets
bucket = each.value
tags = {
app = each.key
}
}The for_each = local.buckets line then creates one bucket resource for each entry in the map. This is much cleaner than writing three separate resource blocks or using count with index gymnastics.
You can also use for to filter and transform lists. Suppose you have a list of security group rules and need only the ones for a specific protocol:
variable "sg_rules" {
type = list(object({
protocol = string
port = number
cidr = string
}))
}
locals {
https_only = [
for rule in var.sg_rules :
rule if rule.protocol == "https"
]
}The if clause filters the collection, returning only items that match your condition.
Conditionals: the ternary operator
Terraform's conditional syntax follows the standard ternary pattern: condition ? true_value : false_value. We use this when a resource argument or local value should change based on environment, input variables, or other factors.
A real-world example: enabling detailed monitoring only in production environments.
variable "environment" {
type = string
}
resource "aws_db_instance" "main" {
allocated_storage = 20
instance_class = "db.t3.micro"
enabled_cloudwatch_logs_exports = var.environment == "prod" ? ["error", "general", "slowquery"] : []
backup_retention_period = var.environment == "prod" ? 30 : 7
}You can also nest conditionals for more complex logic, though readability suffers quickly. If you find yourself nesting more than one level, consider extracting the logic into a local value or variable.
locals {
instance_type = var.environment == "prod" ? (
var.high_volume ? "db.r5.2xlarge" : "db.r5.large"
) : "db.t3.micro"
}Essential functions: merge, lookup, and coalesce
Terraform includes dozens of built-in functions. Three stand out as essential for daily work: merge, lookup, and coalesce.
merge combines multiple maps into one. This is invaluable when building tags or configuration objects that inherit from defaults.
locals {
default_tags = {
Environment = var.environment
ManagedBy = "Terraform"
Project = var.project_name
}
app_tags = merge(
local.default_tags,
{
AppName = "api-service",
Version = "1.2.3"
}
)
}
resource "aws_instance" "api" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = local.app_tags
}lookup retrieves a value from a map by key, with an optional default if the key doesn't exist. This prevents errors when working with dynamic maps.
variable "instance_sizes" {
type = map(string)
default = {
small = "t3.micro"
medium = "t3.small"
large = "t3.medium"
}
}
locals {
size = lookup(var.instance_sizes, var.app_size, "t3.micro")
}If var.app_size is "xlarge" and that key doesn't exist in the map, lookup returns "t3.micro" instead of throwing an error.
coalesce returns the first non-null, non-empty-string value from a list of arguments. We use this for fallback logic—prefer an explicit variable, fall back to a computed value, then use a sensible default.
locals {
bucket_name = coalesce(
var.custom_bucket_name,
"${var.environment}-${data.aws_caller_identity.current.account_id}-bucket"
)
}If var.custom_bucket_name is null or empty, the computed name is used instead.
Testing expressions in the Terraform console
Before committing an expression to production code, test it in the terraform console. This interactive REPL lets you evaluate expressions against your current state and configuration without running a plan.
# Start the console
$ terraform console
# Test a for expression
> [for app in var.app_names : "${var.environment}-${app}"]
["prod-api", "prod-web", "prod-worker"]
# Test a conditional
> var.environment == "prod" ? 30 : 7
30
# Test merge
> merge({a = 1}, {b = 2})
{
"a" = 1
"b" = 2
}
# Test lookup
> lookup({x = "foo", y = "bar"}, "z", "default")
"default"
# Exit
> exitThis hands-on testing catches mistakes before they reach code review. We recommend testing any expression longer than one line or any logic that impacts resource naming or counts.
Combining expressions for powerful abstractions
Real infrastructure problems often require layering multiple concepts. Consider building security group rules from a list of objects, filtering by environment, and merging with defaults:
variable "custom_rules" {
type = list(object({
environment = string
protocol = string
port = number
cidr_blocks = list(string)
}))
default = []
}
locals {
default_rules = [
{
environment = "all"
protocol = "tcp"
port = 443
cidr_blocks = ["0.0.0.0/0"]
}
]
all_rules = concat(local.default_rules, var.custom_rules)
env_rules = [
for rule in local.all_rules :
rule if rule.environment == "all" || rule.environment == var.environment
]
rules_map = {
for idx, rule in local.env_rules :
"${rule.protocol}-${rule.port}" => rule
}
}
resource "aws_security_group_rule" "rules" {
for_each = local.rules_map
security_group_id = aws_security_group.main.id
type = "ingress"
protocol = each.value.protocol
from_port = each.value.port
to_port = each.value.port
cidr_blocks = each.value.cidr_blocks
}This pattern gives you flexibility: you have sensible defaults, environment-specific overrides, and a clean resource declaration.
Common mistakes
- Forgetting that for expressions evaluate at plan time: If you're trying to loop over infrastructure that doesn't exist yet or compute values that depend on apply-time outputs, you'll hit errors. Use
depends_onor restructure your locals to reference only planned resources. - Nesting conditionals too deeply: Three or more levels of ternary operators become unreadable. Extract complex logic into separate local values with descriptive names.
- Using lookup without a default: If the key doesn't exist and you omit the default argument, Terraform throws an error. Always provide a sensible fallback unless you want to fail loudly.
- Treating for_each map keys carelessly: Map key order is not guaranteed in Terraform. If you rely on a specific order or generate keys that aren't unique, resource recreation happens unexpectedly. Use stable, descriptive keys.
- Mixing for_each with splat syntax carelessly:
aws_instance.web[*].idreturns a list, not a map. If you need a map for downstream logic, use a for expression instead.
Key takeaways
- Use
forexpressions to transform lists and maps; they pair perfectly withfor_eachto build dynamic resource counts. - Ternary conditionals let you branch logic based on variables or attributes; keep them simple and extract complex nesting into locals.
- Lean on
merge,lookup, andcoalesceto build resilient, composable configurations with sensible defaults. - Test expressions in
terraform consolebefore committing them; it's faster and safer than repeated plan cycles. - Combine expressions layered—filters, transforms, and conditionals together—to keep resource declarations clean and configuration DRY.






Comments
Loading comments…