Dynamic Blocks & Expressions
Some resource types have nested blocks — ingress rules in a security group, environment variables in an ECS task, lifecycle rules in an S3 bucket — that you need to repeat based on a variable-length list. Hardcoding each block is fragile. Dynamic blocks let you generate them from a collection. Combined with Terraform's expression language — for expressions, conditionals, splat operators, and string templates — you can write configurations that are both concise and flexible.
Dynamic Blocks
A dynamic block generates repeated nested blocks from a collection. The syntax wraps any nested block type:
resource "aws_security_group" "app" {
name = "app-sg"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.from_port
to_port = ingress.value.to_port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
}
}
}
With variable:
variable "ingress_rules" {
type = list(object({
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
default = [
{ from_port = 80, to_port = 80, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] },
{ from_port = 443, to_port = 443, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] },
{ from_port = 22, to_port = 22, protocol = "tcp", cidr_blocks = ["10.0.0.0/8"] },
]
}
Terraform generates one ingress block per list element. Inside the content block, ingress.value refers to the current element (the iterator name matches the dynamic block label).
Compare this to the equivalent without dynamic blocks — if you had 10 ingress rules, you'd write 10 identical-but-different blocks. Adding a new rule means editing the config. With dynamic blocks, adding a rule means adding one element to a variable.
Custom Iterator Name
By default, the iterator variable shares the name of the block type. When this conflicts with another variable or when nesting dynamic blocks, set a custom name with iterator:
resource "aws_security_group" "app" {
name = "app-sg"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
iterator = rule # rename the iterator
content {
from_port = rule.value.from_port
to_port = rule.value.to_port
protocol = rule.value.protocol
cidr_blocks = rule.value.cidr_blocks
}
}
}
Inside the content block, rule.key is the index (for lists) or key (for maps) and rule.value is the element.
Nested Dynamic Blocks
Dynamic blocks can nest — useful for resources like ALB listener rules that have nested conditions:
resource "aws_lb_listener_rule" "routing" {
listener_arn = aws_lb_listener.front.arn
priority = 100
dynamic "condition" {
for_each = var.conditions
content {
dynamic "path_pattern" {
for_each = condition.value.path_patterns
content {
values = [path_pattern.value]
}
}
dynamic "host_header" {
for_each = condition.value.host_headers
content {
values = condition.value.host_headers
}
}
}
}
action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
A configuration where every nested block is dynamic is hard to audit — the plan output is generated, not textual. Prefer static blocks when the count is small and fixed. Use dynamic blocks only when the count genuinely varies at runtime.
for Expressions
for expressions transform collections — lists, maps, or sets — into new collections. They're the Terraform equivalent of a map/filter operation.
List to list
# Uppercase all strings in a list
variable "names" {
default = ["alice", "bob", "carol"]
}
locals {
upper_names = [for n in var.names : upper(n)]
# Result: ["ALICE", "BOB", "CAROL"]
}
List to map
variable "users" {
type = list(object({ name = string, role = string }))
}
locals {
user_roles = { for u in var.users : u.name => u.role }
# Result: { "alice" = "admin", "bob" = "viewer" }
}
Filtering with if
variable "instances" {
type = list(object({ id = string, env = string }))
}
locals {
prod_ids = [for i in var.instances : i.id if i.env == "prod"]
}
Map to list
variable "tags" {
type = map(string)
default = { env = "prod", team = "platform" }
}
locals {
tag_lines = [for k, v in var.tags : "${k}=${v}"]
# Result: ["env=prod", "team=platform"]
}
When iterating a map, the for expression receives both key and value: for k, v in map.
Conditional Expressions
The ternary operator selects between two values based on a boolean condition:
variable "environment" {
type = string
default = "prod"
}
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"
}
Conditionals are also used to make optional attributes return null, which Terraform treats as "omit this attribute":
resource "aws_db_instance" "main" {
# Only set multi_az in production
multi_az = var.environment == "prod" ? true : null
# Only set a final snapshot identifier in production
final_snapshot_identifier = var.environment == "prod" ? "prod-final-snapshot" : null
skip_final_snapshot = var.environment == "prod" ? false : true
}
Splat Expressions
Splat expressions extract an attribute from each element in a list. They're shorthand for a for expression that collects one attribute:
resource "aws_instance" "web" {
count = 3
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
}
# Splat: collect all private IPs
output "web_ips" {
value = aws_instance.web[*].private_ip
# Equivalent: [for i in aws_instance.web : i.private_ip]
}
The [*] operator works on lists and sets. For for_each resources (which produce maps), use values() first:
resource "aws_s3_bucket" "store" {
for_each = var.buckets
bucket = "myapp-${each.key}"
}
output "bucket_arns" {
value = values(aws_s3_bucket.store)[*].arn
}
String Templates
Terraform's %{} syntax enables loops and conditionals inside strings — useful for generating scripts or configuration files:
variable "servers" {
default = ["web-1", "web-2", "web-3"]
}
output "hosts_file" {
value = <<-EOT
%{ for s in var.servers ~}
10.0.0.${index(var.servers, s) + 1} ${s}
%{ endfor ~}
EOT
}
The ~ after %{} strips trailing whitespace and newlines, keeping output clean. Conditional directives:
output "greeting" {
value = "Hello, ${var.name != "" ? var.name : "world"}!"
}
output "config_block" {
value = <<-EOT
server_name ${var.hostname};
%{ if var.ssl_enabled ~}
ssl on;
ssl_certificate /etc/ssl/cert.pem;
%{ endif ~}
EOT
}
Key Takeaways
- Dynamic blocks generate repeated nested blocks from a list or map. Use them when a resource has a variable-length list of nested blocks (ingress rules, environment variables, lifecycle rules).
- The iterator variable defaults to the block label name. Override it with
iterator = nameto avoid conflicts or improve clarity in nested dynamic blocks. forexpressions transform any collection: list→list, list→map, map→list, with optionaliffiltering. The{ for k, v in map : k => transform(v) }pattern is ubiquitous for reshaping data.- Conditional expressions (
condition ? true_val : false_val) are the primary tool for environment-specific configuration. Returningnullomits the attribute entirely. - Splat (
[*]) extracts one attribute from all elements of a list. Forfor_eachmaps, wrap withvalues()first. - String templates (
%{ for }...%{ endfor }) let you generate multi-line configuration text with loops and conditionals. Use~to strip whitespace.