Terraform Expressions — Conditionals, Loops, and Dynamic Blocks
Terraform's HCL is not a general-purpose programming language, but it is far more powerful than a static config file. You can conditionally create resources, loop over lists and maps, generate repeated blocks dynamically, and build complex data transformations — all without leaving your .tf files. These expressions are what turn a one-off configuration into a reusable platform.
Conditional Expressions
The ternary operator is your if/else in Terraform. The syntax is condition ? true_value : false_value.
variable "environment" {
type = string
default = "dev"
}
resource "aws_instance" "app" {
ami = var.ami_id
instance_type = var.environment == "production" ? "t3.large" : "t3.micro"
root_block_device {
volume_size = var.environment == "production" ? 100 : 20
volume_type = var.environment == "production" ? "gp3" : "gp2"
}
monitoring = var.environment == "production" ? true : false
tags = {
Name = "app-${var.environment}"
Environment = var.environment
}
}
You can also use conditionals to create or skip entire resources:
variable "create_bastion" {
type = bool
default = false
}
# This resource only exists when create_bastion is true
resource "aws_instance" "bastion" {
count = var.create_bastion ? 1 : 0
ami = var.ami_id
instance_type = "t3.micro"
subnet_id = var.public_subnet_id
tags = {
Name = "bastion-${var.environment}"
}
}
When count = 0, Terraform skips the resource entirely. When count = 1, it creates one instance. This is the standard pattern for optional resources.
count — Simple Loops
The count meta-argument creates multiple copies of a resource. Each copy gets an index via count.index.
variable "instance_count" {
type = number
default = 3
}
resource "aws_instance" "web" {
count = var.instance_count
ami = var.ami_id
instance_type = "t3.micro"
subnet_id = var.subnet_ids[count.index % length(var.subnet_ids)]
tags = {
Name = "web-${count.index + 1}"
}
}
output "instance_ids" {
value = aws_instance.web[*].id
}
output "instance_ips" {
value = aws_instance.web[*].public_ip
}
The problem with count: if you have 3 instances and remove the second one, Terraform renumbers everything. Instance 3 becomes instance 2, which means Terraform destroys and recreates it. This is where for_each is better.
for_each — Loops Over Maps and Sets
for_each iterates over a map or set of strings. Each resource is identified by a key, so removing an item from the middle does not affect the others.
variable "instances" {
type = map(object({
instance_type = string
subnet_id = string
}))
default = {
web = {
instance_type = "t3.micro"
subnet_id = "subnet-abc123"
}
api = {
instance_type = "t3.small"
subnet_id = "subnet-def456"
}
worker = {
instance_type = "t3.medium"
subnet_id = "subnet-ghi789"
}
}
}
resource "aws_instance" "app" {
for_each = var.instances
ami = var.ami_id
instance_type = each.value.instance_type
subnet_id = each.value.subnet_id
tags = {
Name = "${each.key}-server"
Role = each.key
}
}
# Access a specific instance
output "api_server_ip" {
value = aws_instance.app["api"].public_ip
}
If you remove the worker entry from the map, Terraform only destroys the worker instance. The web and api instances are untouched. This is why for_each is preferred over count for most use cases.
for_each with a Set of Strings
variable "iam_users" {
type = set(string)
default = ["alice", "bob", "charlie"]
}
resource "aws_iam_user" "team" {
for_each = var.iam_users
name = each.value
tags = {
ManagedBy = "terraform"
}
}
for Expressions — List and Map Comprehensions
for expressions transform one collection into another. They work like list comprehensions in Python.
variable "users" {
type = list(object({
name = string
role = string
email = string
}))
default = [
{ name = "alice", role = "admin", email = "alice@example.com" },
{ name = "bob", role = "developer", email = "bob@example.com" },
{ name = "charlie", role = "admin", email = "charlie@example.com" },
]
}
# Transform a list into a different list
locals {
user_emails = [for user in var.users : user.email]
# Result: ["alice@example.com", "bob@example.com", "charlie@example.com"]
admin_names = [for user in var.users : user.name if user.role == "admin"]
# Result: ["alice", "charlie"]
upper_names = [for user in var.users : upper(user.name)]
# Result: ["ALICE", "BOB", "CHARLIE"]
# Transform a list into a map
user_roles = { for user in var.users : user.name => user.role }
# Result: { alice = "admin", bob = "developer", charlie = "admin" }
# Map of email addresses keyed by name
user_email_map = { for user in var.users : user.name => user.email }
# Result: { alice = "alice@example.com", bob = "bob@example.com", ... }
}
Dynamic Blocks
Some resources have repeated nested blocks — like ingress rules in a security group. Without dynamic blocks, you would have to write each one manually. Dynamic blocks generate them from a collection.
variable "ingress_rules" {
type = list(object({
description = string
port = number
protocol = string
cidr_blocks = list(string)
}))
default = [
{
description = "HTTPS"
port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
},
{
description = "HTTP"
port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
},
{
description = "SSH from office"
port = 22
protocol = "tcp"
cidr_blocks = ["203.0.113.0/24"]
},
]
}
resource "aws_security_group" "app" {
name = "app-sg"
description = "Application security group"
vpc_id = var.vpc_id
dynamic "ingress" {
for_each = var.ingress_rules
content {
description = ingress.value.description
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "app-sg"
}
}
The dynamic block replaces what would have been three separate ingress blocks. Add a new rule to the variable, and the security group updates automatically.
Splat Expressions
The splat operator [*] is a concise way to extract a single attribute from a list of objects.
resource "aws_instance" "cluster" {
count = 3
ami = var.ami_id
instance_type = "t3.micro"
}
# These two are equivalent:
output "instance_ids_splat" {
value = aws_instance.cluster[*].id
}
output "instance_ids_for" {
value = [for instance in aws_instance.cluster : instance.id]
}
The splat operator also works with nested attributes:
output "private_ips" {
value = aws_instance.cluster[*].private_ip
}
output "availability_zones" {
value = aws_instance.cluster[*].availability_zone
}
Null Values and Conditional Arguments
Use null to make an argument behave as if it was not set at all. This is different from an empty string.
variable "key_pair_name" {
type = string
default = null # No SSH key by default
}
resource "aws_instance" "app" {
ami = var.ami_id
instance_type = "t3.micro"
# If key_pair_name is null, this argument is omitted entirely
key_name = var.key_pair_name
tags = {
Name = "app-server"
}
}
try() and can() Functions
try() evaluates an expression and returns a fallback if it fails. can() returns a boolean indicating whether an expression would succeed.
variable "config" {
type = any
default = {
database = {
port = 5432
}
}
}
locals {
# try() returns the first expression that succeeds
db_port = try(var.config.database.port, 3306)
# Result: 5432 (found in config)
cache_port = try(var.config.cache.port, 6379)
# Result: 6379 (config.cache does not exist, falls back)
# can() checks if an expression is valid
has_database = can(var.config.database)
# Result: true
has_cache = can(var.config.cache)
# Result: false
}
Use can() in variable validation:
variable "cidr_block" {
type = string
validation {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "Must be a valid CIDR block."
}
}
Practical Example — Putting It All Together
Here is a realistic configuration that uses conditionals, for_each, dynamic blocks, and for expressions together:
variable "services" {
type = map(object({
port = number
instance_type = string
instance_count = number
public = bool
}))
default = {
web = { port = 443, instance_type = "t3.small", instance_count = 3, public = true }
api = { port = 8080, instance_type = "t3.medium", instance_count = 2, public = false }
}
}
locals {
# Build a flat map of all instances
all_instances = merge([
for svc_name, svc in var.services : {
for i in range(svc.instance_count) :
"${svc_name}-${i + 1}" => {
service = svc_name
instance_type = svc.instance_type
public = svc.public
index = i
}
}
]...)
# Filter for only public services
public_services = { for name, svc in var.services : name => svc if svc.public }
}
# Create instances from the flat map
resource "aws_instance" "service" {
for_each = local.all_instances
ami = var.ami_id
instance_type = each.value.instance_type
subnet_id = each.value.public ? var.public_subnets[each.value.index % length(var.public_subnets)] : var.private_subnets[each.value.index % length(var.private_subnets)]
tags = {
Name = each.key
Service = each.value.service
Public = tostring(each.value.public)
}
}
output "service_instances" {
value = {
for key, instance in aws_instance.service :
key => {
id = instance.id
ip = instance.private_ip
}
}
}
This creates 5 instances total (3 web + 2 api) from a single resource block, distributes them across subnets, and outputs a structured map of all instances.
Wrapping Up
Terraform expressions turn static infrastructure definitions into flexible, reusable platforms. Start with conditionals and count for simple cases, graduate to for_each for production workloads, and use dynamic blocks when you need repeated nested configurations. The try() and can() functions are the safety nets that make your code resilient to missing or unexpected data.
This wraps up the core Terraform language fundamentals. From here, the next frontier is Terraform modules — packaging your infrastructure into reusable, shareable components that your entire team can consume.
