Terraform Dynamic Block : What, Why and How (with Azure Use Cases)

 

Imagine this, You want to create a re-usable terraform  module, which can be used in multiple environments, to create any number of VNets and subnets.

    • In Development environment, you need to create two virtual networks (vNets), and inside each vNet there will be 3 subnets.
    • In production environment, you need to create 5 vNets, and within each vNet, there will be 4 subnets.

Using reusable Terraform code — how will you do this?

The number of vNets and number of subnets are not the same — so a single for_each loop will not solve this. You need something that can iterate inside each vNet, independently.

Let’s take another example.

You added a ddos_protection_plan block in your reusable vNet module, but from Azure’s point of view, this block is optional. Some environments may skip it, while some environment might need it.

But how do you tell Terraform that this block is optional?

If you use a static block, Terraform will expect this block to be always present — which is not what you want.

Well….if you have faced any of these scenarios, you are in the right place.

In this article, we will learn what a dynamic block is, why it exists, and exactly how to use it to solve such practical challenges — with real Azure examples.


🚀 What is a dynamic block?

A dynamic block allows you to generate nested blocks programmatically inside a resource. This is useful when:

    • The nested block needs independent iteration from the parent.
    • The nested block is optional.
    • You want to make your code reusable and flexible.

📌 Why Use dynamic Block? (Key Use Cases)

Use Case Description
Independent nested iteration Nested block needs different iteration than parent
Optional nested blocks Nested block should be conditionally included
Complex nested structures Nested blocks inside nested blocks
Module reusability Module should accept optional nested configs

1️⃣ Independent Iteration within Parent Blocks

Let’s go back to the scenario where you had to create multiple VNets, each having a different number of subnets .

In such cases, a single for_each at the VNet level is not enough — because subnets inside each VNet need their own separate iteration.

This is exactly where dynamic block helps.

With dynamic, you can define a nested block (subnet in this case) and make it iterate independently using its own for_each.

This allows each VNet to generate exactly the number of subnets it needs — no more, no less.

variable "vnet_config" {}

resource "azurerm_virtual_network" "example" {
  for_each            = var.vnet_config
  name                = each.value.name
  location            = each.value.location
  resource_group_name = each.value.resource_group_name
  address_space       = each.value.address_space

  dynamic "subnet" {
    for_each = lookup(each.value, "subnets", [])
    content {
      name           = subnet.value.name
      address_prefix = subnet.value.address_prefix
    }
  }
}

✅ Subnets can now be iterated independently per vNet.

 


2️⃣ Optional Nested Blocks (Recommended Pattern)

❗ The Problem with Static Nested Blocks (Important!)

Some Azure nested blocks are optional — for example, ddos_protection_plan in Virtual Network or delegation in subnets.
You can easily create the resource without defining these blocks in Azure Portal or CLI.

However, when you define them statically in Terraform like below example, Terraform will now expect this block to exist always, every time you run terraform plan or apply.

resource "azurerm_virtual_network" "example" {
  name                = "my-vnet"
  location            = "eastus"
  resource_group_name = "my-rg"
  address_space       = ["10.0.0.0/16"]

  ddos_protection_plan {
    id = var.ddos_protection_plan_id
  }
}

And if the block (being mandatory) is missing, you will get error message like this :

Error: Missing required argument

  on main.tf line 7, in resource "azurerm_virtual_network" "example":
   7:   ddos_protection_plan {

The argument "id" is required, but no definition was found.

✅ This means — even though Azure allows this block to be optional, Terraform does not know that.

✅ In Terraform logic → Since you defined the block statically, it becomes mandatory during plan and apply.

But Why Do We Skip A Block ?

When you start building reusable Terraform modules or multi-environment infrastructure (Dev, Test, Prod, DR), this becomes a big headache:

Example:

    • In Production → DDOS protection is mandatory → You have DDOS plan → Works fine.
    • In Development → DDOS protection is not needed → No DDOS plan → Plan fails because id is missing.

You cannot skip the block → because it is defined statically.
You cannot pass null → because id is a required field.

Simple Rule :
❗ Static nested blocks → Always mandatory → Cannot skip if not defined in tfvars
✅ Dynamic nested blocks + conditional for_each → Optional → Can skip if input is absent
Bonus Tip (Best Practice)
If your module or resource is going to be used across different environments, always prefer dynamic block for optional blocks. This keeps your module flexible, safe, and environment agonistic.

✅ Dynamic Block makes it optional

To overcome the problem with the static (mandatory) block,  Use dynamic block + conditional for_each → So in this way you are telling Terraform: ” Hey, this block is optional. If you find this block in tfvars, take the values ; if not, don’t worry, please ignore and proceed”

variable "vnet_config" {}

data "azurerm_network_ddos_protection_plan" "vnet" {
  for_each = var.vnet_config

  name                = lookup(each.value, "ddos_protection_plan_name", null)
  resource_group_name = lookup(each.value, "ddos_protection_plan_rg", null)
}

resource "azurerm_virtual_network" "example" {
  for_each            = var.vnet_config
  name                = each.value.name
  location            = each.value.location
  resource_group_name = each.value.resource_group_name
  address_space       = each.value.address_space

  dynamic "ddos_protection_plan" {
    for_each = lookup(each.value, "ddos_protection_plan_name", null) != null ? [1] : []
    content {
      id = data.azurerm_network_ddos_protection_plan.vnet[each.key].id
    }
  }

  dynamic "subnet" {
    for_each = lookup(each.value, "subnets", [])
    content {
      name           = subnet.value.name
      address_prefix = subnet.value.address_prefix
    }
  }
}

✅ If ddos_protection_plan_name is present → Block is created

✅ If absent → Block is skipped → No plan error

📦 Example tfvars

Block present:

vnet_config = {
  vnet1 = {
    name                = "vnet1"
    location            = "eastus"
    resource_group_name = "my-rg"
    address_space       = ["10.0.0.0/16"]

    ddos_protection_plan_name = "ddos-plan-1"
    ddos_protection_plan_rg   = "network-rg"

    subnets = [
      { name = "subnet1", address_prefix = "10.0.1.0/24" },
      { name = "subnet2", address_prefix = "10.0.2.0/24" }
    ]
  }
}

Block absent:

vnet_config = {
  vnet2 = {
    name                = "vnet2"
    location            = "eastus2"
    resource_group_name = "my-rg"
    address_space       = ["10.1.0.0/16"]

    subnets = [
      { name = "subnetA", address_prefix = "10.1.1.0/24" }
    ]
  }
}
📌 Why use data block for DDOS Protection Plan ID?
Instead of asking users to pass complex IDs, they only pass name and optionally resource group.
The data block resolves the ID → making configuration simpler and tfvars user-friendly.
📌 Why use each.key for data lookup?
each.key refers to the parent resource iteration (vnet key).
each.value would not work here because data block loops over the parent resource, not inside dynamic blocks.

🔎 Best Practice Decision Matrix

Block Type Block Optionality Separate Iteration Required Recommended
Optional (Azure optional) Yes No Dynamic block + conditional for_each
Optional (Azure optional) Yes Yes Dynamic block + independent for_each
Mandatory (Azure mandatory) No No Static nested block
Mandatory (Azure mandatory) Yes Yes Dynamic block + simple for_each

🚦 Final Rule of Thumb

  1. Use for Independent Iteration

    • Ideal when you need nested loops (e.g., subnets per VNet, rules per NSG).

    • Ensures child blocks iterate independently of the parent resource.

  2. Use for Optional Blocks

    • Helps conditionally include/exclude nested configurations (e.g., optional tags, backup policies).

    • Controlled via for_each with empty collections ([] or {}) to skip.

  3. Dynamic Blocks Aren’t Automatically Optional

    • Must explicitly manage skipping via:

      • Empty for_each collections, or

      • Conditional logic (var.enable_feature ? { ... } : {}).

  4. Avoid Overuse & Deep Nesting

    • Prefer flat structures where possible.

    • Break deeply nested logic into separate modules for clarity.

    • Max 2 levels of nesting recommended (e.g., VNet → Subnet).

  5. Test with terraform plan

    • Verify:

      • Expected iterations appear.

      • Conditions (e.g., empty for_each) skip blocks correctly.

    • Test edge cases (empty inputs, null values).

  6. Validate with terraform apply in Sandbox

    • Confirm:

      • All resources deploy correctly.

      • Nested blocks (e.g., subnets) align with iterations.

    • Use terraform state list to audit dynamic resources.

When to Avoid Dynamic Blocks

    • Simple, fixed nested blocks → Hardcode instead.

    • Single-level iterations → Prefer for_each at resource level.

    • Overly complex logic → Refactor into modules.

Key Principle

“Dynamic blocks are powerful but should simplify—not complicate—your code.” Always prioritize readability and test rigorously.

 

Leave a comment