Terraform Dynamic Blocks: Generate Nested Configuration Without Hiding Resources
Use Terraform dynamic blocks for repeated provider-defined nested blocks, distinguish them from for_each resources, and keep module interfaces understandable.
Terraform configuration can assign expressions to arguments, but some provider resources also accept repeatable nested blocks. When the number of those blocks comes from an input collection, a dynamic block can generate them. It behaves similarly to a for expression but emits configuration blocks rather than a list or object value.
Dynamic blocks solve a narrow problem: describe repeated nested configuration in a reusable resource or module. They do not create independent Terraform resources, do not receive their own state addresses, and cannot generate every kind of block in the language. Overusing them can obscure a provider schema and turn a simple resource into a small configuration generator that callers struggle to understand.
Arguments and blocks are different shapes
An argument assigns a value with name-equals-expression syntax. A nested block is a structured section defined by the provider schema. If a resource expects a repeatable block named setting, assigning a list to an argument named setting is not necessarily equivalent. HCL distinguishes these shapes, and the provider determines which nested block types a resource accepts.
Use a for expression when the provider expects a list, map, or object value as an argument. Use a dynamic block when the provider schema requires one nested block per entry. The body of a dynamic block describes the generated nested block, and its label is the provider-defined block type to generate.
variable "settings" {
type = map(object({
namespace = string
name = string
value = string
}))
}
resource "example_service" "application" {
name = var.service_name
dynamic "setting" {
for_each = var.settings
iterator = item
content {
namespace = item.value.namespace
name = item.value.name
value = item.value.value
}
}
}
The example is schematic: the selected provider resource must actually define a repeatable nested block named setting with those attributes. Dynamic blocks do not add a new nested schema to a provider. Check the resource documentation and schema for the exact block name, argument types, required fields, and whether ordering is significant.
Iterator scope and labels
The dynamic block label identifies the nested block Terraform should generate. The optional iterator argument names the temporary object representing one collection entry. If iterator is omitted, the block label is also used as the iterator name. The iterator has key and value attributes; for maps, key is the map key, and for lists it is the index. For sets, key and value are the same element.
Set iterator names explicitly when the block is nested or the label is an awkward variable name. A second nested dynamic block should use a different iterator so expressions clearly identify which collection entry they reference. This avoids mistakes where an inner value accidentally refers to the outer object.
The optional labels argument supplies the labels for each generated block when the provider schema defines labeled nested blocks. Each generated label must match the provider’s schema requirements. Do not invent labels because they make the HCL look more expressive; they are part of the nested block syntax understood by the provider.
dynamic "setting" {
for_each = var.settings
iterator = item
content {
namespace = item.value.namespace
name = item.key
value = item.value.value
}
}
This example uses the map key as the generated setting name and the value object for the other fields. That is a deliberate interface choice: callers identify a setting once, and the implementation does not require a separate name field that could disagree with the key. If the provider requires both a key and a name field, validate that they are consistent rather than silently choosing one.
Empty, optional, and nested collections
An empty collection generates no nested blocks. This is useful for optional provider settings, but it also means that an empty or accidentally omitted input can remove existing configuration. Decide whether an empty collection means “use provider defaults,” “disable this feature,” or “the caller omitted required configuration.” Encode that policy in variable defaults, validation, and plan review.
Normalize an optional input before using it when null is allowed. For example, a local value can turn null into an empty list so the dynamic block always receives a collection. Keep normalization visible and avoid using broad fallback expressions that hide malformed input:
locals {
normalized_settings = var.settings == null ? {} : var.settings
}
resource "example_service" "application" {
name = var.service_name
dynamic "setting" {
for_each = local.normalized_settings
iterator = item
content {
namespace = item.value.namespace
name = item.value.name
value = item.value.value
}
}
}
Nested dynamic blocks are possible when a provider resource contains multiple repeatable levels. Use a well-typed input object and distinct iterator names for each level. Nested generation should remain shallow enough that a reviewer can map each input field to the provider block it produces. If a module mirrors nearly every provider field through a deeply nested object, consider whether the abstraction is adding useful behavior or merely hiding the provider resource.
Dynamic blocks do not create resource instances
A dynamic block is not equivalent to a resource block with for_each. A resource for_each creates separate managed instances with distinct addresses and lifecycle decisions. A dynamic block expands nested configuration inside one parent resource. The provider decides how changes to those nested values map to API updates, replacements, or state representation.
This distinction affects identity and removal. Removing a resource for_each key removes one Terraform-managed object. Removing a dynamic block input removes or changes part of its parent resource configuration; it does not destroy a separately addressable child resource. Inspect the provider’s behavior for changes to that nested field. Some fields update in place, some require replacement, and some have provider-specific drift semantics.
For an independently owned object with separate lifecycle, permissions, dependencies, or outputs, model it as a separate resource instead of trying to hide it inside a dynamic block. Use dynamic generation only for nested blocks the provider defines as part of the parent object.
Meta-arguments cannot be generated dynamically
Terraform must process certain meta-arguments before it can safely evaluate normal expressions. A dynamic block cannot generate lifecycle, provisioner, or other meta-argument blocks. The provider schema is evaluated after Terraform has determined graph structure and lifecycle behavior, so dynamic generation is limited to provider-defined nested configuration blocks supported in the enclosing construct.
Do not use a dynamic block to attempt one lifecycle policy for some instances and a different lifecycle policy for others. Split the resources into explicit resource blocks or module boundaries when lifecycle behavior differs. Likewise, provisioner blocks should not be hidden behind a dynamic loop; provisioners have special execution semantics and are not a substitute for a real provider resource or deployment system.
Avoid using dynamic blocks for simple values
If the provider expects an argument value, use a normal expression or for expression. If there is one known nested block, write it literally. Literal configuration makes required fields, defaults, and review intent easier to see. Dynamic blocks are most valuable when a reusable abstraction genuinely needs to emit zero or more provider-defined blocks.
Avoid constructing a dynamic block around a fixed single item merely to make the configuration look generic. Avoid duplicating a provider’s entire schema into a module input without adding validation, sensible defaults, or stable behavior. Generic pass-through modules can be difficult to evolve because the caller’s interface is coupled to every provider schema change.
When a module needs a small subset of provider options, model only those options in the variable type and validation. Transform the typed values into the provider’s nested block shape. Document defaults and empty-input behavior. This creates a controlled interface instead of an opaque forwarding layer.
Test the generated configuration
Test zero, one, and multiple entries. A zero-entry case verifies optional behavior. A single entry verifies that the iterator and field mappings are correct. Multiple entries verify that no value is accidentally reused from the first item and that map keys or list order are handled as intended.
Plan-based Terraform tests with a mock provider can verify local configuration decisions without creating cloud infrastructure. Assert on the parent resource’s arguments or nested values where the provider schema and test framework expose them. Mock tests do not prove that an API accepts the combination or that the provider implements an update in place. Use a disposable integration environment when those remote behaviors matter.
Review generated block changes in a plan as carefully as top-level resources. A diff may show a nested argument removed, a collection reordered, or a parent resource replaced. Understand whether the provider schema treats the block as a list, set, or single nested object and how that affects stable comparison. Do not dismiss a large nested diff as formatting noise until the provider’s behavior is understood.
If the dynamic input comes from a data source or a computed resource attribute, the collection may be unknown during planning. Terraform then cannot fully render the nested blocks until the value is available. Keep the inputs plan-known when practical and inspect any resulting unknowns before apply. A dynamic block does not remove the normal evaluation and dependency rules of Terraform expressions.
Keep the abstraction readable
Use precise object types so callers know the nested structure expected. Prefer map keys that represent durable names when identity is useful; prefer lists only when order matters. Validate duplicate or incompatible settings at the module boundary. Use iterator names that express the data role, and keep nested levels small.
Before adding a dynamic block, ask whether a literal block is clearer, whether a for expression would produce the required argument type, and whether the nested item needs its own lifecycle. Then inspect the provider documentation, write a test for empty and populated input, and review the plan generated by each case.
Dynamic blocks are a configuration-generation tool, not a general loop over infrastructure. Use them to express repeated provider-defined nested blocks while keeping resource identity, lifecycle, and caller intent explicit. The best dynamic block is the smallest one that removes meaningful duplication without hiding the schema it configures.
Related:
- Terraform Module Input Contracts: Object Types, Optional Attributes, and Null
- Terraform for_each: Stable Resource Identity, Safe Refactors, and Plan-Time Keys
Sources: