Dynamic Blocks
Many Terraform resources accept nested blocks — repeated configuration blocks like ingress rules on a security group or setting blocks on an Elastic Beanstalk environment. When the number of these blocks depends on a variable or a computed collection, you can’t hardcode them. The dynamic block solves this: it expands a single template into zero or more nested blocks, driven by any iterable value. Think of it as a for expression that produces blocks instead of values.
The problem dynamic blocks solve
Without dynamic, you must write each nested block by hand. That’s fine for one or two static rules, but it breaks down when the rules come from a variable list. Consider a security group whose allowed ports are configurable per environment — you cannot know at authoring time how many ingress blocks you need.
A dynamic block lets you declare the shape of one nested block once, then tell Terraform which collection to iterate over. Terraform generates one nested block per element.
Anatomy of a dynamic block
A dynamic block has three parts:
| Part | Purpose |
|---|---|
| Block label | The name of the nested block being generated (e.g. dynamic "ingress"). |
for_each | The collection to iterate. Can be a list, set, or map. |
iterator | Optional name for the per-element temporary variable (defaults to the block label). |
content | The body emitted for each element, referencing the iterator’s .key / .value. |
The content block is the template. Inside it, you reference the current element through the iterator object, which exposes .key (index for lists/sets, key for maps) and .value (the element itself).
Worked example: configurable security group ingress
Suppose you want a reusable security group module whose ingress rules are passed in as a variable.
variable "ingress_rules" {
description = "Inbound rules to apply to the security group."
type = list(object({
description = string
port = number
cidr_blocks = list(string)
}))
default = [
{
description = "HTTPS"
port = 443
cidr_blocks = ["0.0.0.0/0"]
},
{
description = "SSH from office"
port = 22
cidr_blocks = ["203.0.113.0/24"]
},
]
}
resource "aws_security_group" "web" {
name = "web-sg"
description = "Web tier security group"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
content {
description = ingress.value.description
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = ingress.value.cidr_blocks
}
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Here the iterator defaults to ingress (the block label), so ingress.value is each object from the list. Running a plan shows the generated blocks expanded inline:
Output:
# aws_security_group.web will be created
+ resource "aws_security_group" "web" {
+ name = "web-sg"
+ ingress {
+ description = "HTTPS"
+ from_port = 443
+ to_port = 443
+ protocol = "tcp"
+ cidr_blocks = ["0.0.0.0/0"]
}
+ ingress {
+ description = "SSH from office"
+ from_port = 22
+ to_port = 22
+ protocol = "tcp"
+ cidr_blocks = ["203.0.113.0/24"]
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
Because for_each accepts an empty collection, passing ingress_rules = [] simply produces a security group with no ingress blocks — no special casing required.
Using a named iterator
When you nest dynamic blocks, the default iterator names collide. Use the iterator argument to rename the temporary variable so inner and outer loops stay distinct.
resource "aws_security_group" "tiered" {
name = "tiered-sg"
vpc_id = aws_vpc.main.id
dynamic "ingress" {
for_each = var.ingress_rules
iterator = rule
content {
description = rule.value.description
from_port = rule.value.port
to_port = rule.value.port
protocol = "tcp"
cidr_blocks = rule.value.cidr_blocks
}
}
}
Filtering and transforming the source collection
You rarely iterate a raw variable as-is. Combine dynamic with a for expression to filter or reshape the input. For example, only emit rules that are enabled:
dynamic "ingress" {
for_each = { for r in var.ingress_rules : r.description => r if r.enabled }
content {
description = ingress.value.description
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = ingress.value.cidr_blocks
}
}
Iterating a map (rather than a list) gives ingress.key a stable, meaningful identifier instead of a positional index, which keeps plans cleaner when elements are added or removed.
Tip: When
for_eachis driven by a set or map, the.keyis stable across runs. With a list, the key is the positional index, so inserting an element in the middle shifts every following block. Prefer maps or sets for collections that change over time.
When to use — and when to avoid
Reach for dynamic only when the count of nested blocks is genuinely data-driven. Overusing it makes configuration hard to read.
Warning: Avoid
dynamicfor blocks that are always present and static. Hardcoded nested blocks are clearer, diff better, and are easier to review. The HashiCorp style guide explicitly recommends usingdynamicsparingly.
This syntax is identical in OpenTofu — dynamic, for_each, iterator, and content behave the same way, so modules using dynamic blocks port between the two without changes.
Best practices
- Use
dynamiconly when the number of nested blocks is variable or computed; write static blocks by hand. - Prefer maps or sets over lists for
for_eachso block keys stay stable across edits and produce minimal diffs. - Combine
dynamicwithforexpressions to filter (if) and reshape inputs rather than pre-processing in separate locals where it hurts readability. - Name the iterator with
iteratorwhenever you nest dynamic blocks to avoid shadowed variable names. - Keep
contentbodies flat and readable; if logic grows, move the data shaping into alocalsblock. - Validate that an empty
for_eachcollection produces the intended result — usually zero blocks, which is a feature, not an error.