Taming Terraform Dynamic Blocks Without Making Config Unreadable
Dynamic blocks kill repetition in Terraform, but they're also where readable config goes to die. Here's how to use them deliberately — and when a plain static block is the better call.
- #terraform
- #dynamic-blocks
- #hcl
- #iac
- #modules
- #ai
The first time you discover dynamic blocks in Terraform, they feel like a superpower. You had twelve nearly identical ingress rules in a security group; now you have one dynamic "ingress" and a list. The diff is gorgeous. Six months later you’re staring at a triple-nested dynamic block trying to figure out why one egress rule didn’t render, and the superpower has become a liability.
Dynamic blocks are genuinely useful, but they’re one of the easiest features in HCL to overuse. Here’s how I keep them earning their keep.
What a dynamic block does
A dynamic block generates repeated nested blocks from a collection. Anywhere you’d otherwise hand-write the same nested block over and over — ingress, setting, rule, filter — you can drive it from a variable instead.
The before, with all the repetition:
resource "aws_security_group" "web" {
name = "web"
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
The after, driven by data:
variable "ingress_rules" {
type = list(object({
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
}
resource "aws_security_group" "web" {
name = "web"
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
}
}
}
The dynamic "ingress" label names the block being generated. for_each is the collection. Inside content, the iterator (ingress by default, named after the block) gives you .value and .key.
The readability tax
Notice what just happened. The static version is instantly legible — anyone can read those two ingress rules. The dynamic version requires you to (a) read the variable type, (b) trace the for_each, (c) understand the iterator, and (d) hold all that in your head to know what ports are actually open. You traded repetition for indirection.
That trade is worth it when the collection is long or varies by environment. It is not worth it for two or three fixed rules that never change. My rule of thumb: if you can see all the cases on one screen and they don’t vary, leave them static. Dynamic blocks pay off with length and variability, not as a reflex against any duplication.
Use a typed variable, always
The single biggest thing that keeps dynamic blocks readable is a strongly typed input. When for_each iterates over list(object({...})) with named, typed fields, the content body reads almost like the static version. When it iterates over a loose list(any) or a map of maps you assembled inline, every .value.something becomes a guessing game and Terraform can’t help you catch a typo.
Define the shape explicitly and your future self can read the content block without scrolling up.
Conditionally including a block
A common, clean use is toggling an optional block on and off. Iterate over a one-or-zero-element collection:
dynamic "logging" {
for_each = var.enable_logging ? [1] : []
content {
target_bucket = var.log_bucket
target_prefix = "s3-access/"
}
}
When enable_logging is false, for_each gets an empty list and no logging block is generated. This is the idiomatic way to make a nested block optional, and it reads clearly enough that I’m happy to use it even for a single block.
Where it goes wrong: nesting
The danger zone is nested dynamic blocks. A dynamic "rule" whose content contains a dynamic "condition" is legal, occasionally necessary (think WAF rules or complex IAM policies), and almost always the hardest part of any module to debug. The iterator names collide in your head, and an error message points at a generated block that doesn’t exist in your source.
If you must nest, name your iterators explicitly so the code stops relying on the default:
dynamic "rule" {
for_each = var.rules
iterator = rule
content {
name = rule.value.name
dynamic "condition" {
for_each = rule.value.conditions
iterator = cond
content {
field = cond.value.field
operator = cond.value.operator
}
}
}
}
Explicit iterator names (rule, cond) make the inner references unambiguous. It’s a small thing that saves real confusion when the nesting is unavoidable.
Debugging what a dynamic block produced
When a dynamic block renders the wrong thing, don’t squint at the source — look at the plan. terraform plan expands every dynamic block into concrete nested blocks, so the output shows you exactly what got generated. If a rule is missing, your for_each collection is missing an element; if a field is wrong, trace it back to the object in the variable. The plan is the ground truth.
For trickier cases, terraform console lets you evaluate the for_each expression directly and see the actual list before it ever hits a resource.
A checklist before you reach for dynamic
- Is the collection long, or does it vary by environment? If no, stay static.
- Is the input a typed
object, not a looseany? If no, fix that first. - Are you nesting? If yes, name your iterators and reconsider whether a flatter design works.
- Can a teammate read the
contentblock and know what it produces? If no, you’ve over-abstracted.
Dynamic blocks are a tool for removing meaningful repetition, not all repetition. A little duplication that reads clearly beats clever indirection that nobody can debug at 2am.
When you’re reviewing a PR full of new dynamic blocks, a second reviewer that flags the over-abstracted ones is worth a lot — that’s part of what our AI code review tooling is for. And for more HCL patterns, browse the full set of Terraform guides.
Examples are illustrative. Always confirm generated blocks against terraform plan output before applying.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.