HashiCorp Terraform

HashiCorp Terraform: count vs. for_each vs. Dynamic Blocks

You know that moment when a Terraform plan looks perfectly reasonable right up until it wants to replace half your infrastructure?

It usually starts innocently. Maybe you have three Azure subnets, three network security rules, or a handful of storage containers. You reach for count because it is simple, readable, and gets the job done. Then someone removes the second item from a list, Terraform shifts the indexes, and suddenly your plan looks like it is playing musical chairs with production resources.

Terraform gives us several ways to repeat configuration: countfor_each, and dynamic blocks. They are related, but they solve different problems. Used well, they make infrastructure code clean and predictable. Used carelessly, they create state-addressing surprises that are about as fun as debugging DNS on a Friday afternoon.

Let’s walk through how these tools differ, when count becomes risky, how to handle computed values, and how to refactor safely in AzureRM-based Terraform configurations.

The Big Difference: Numbers, Keys, and Nested Blocks

Terraform’s repetition features are not interchangeable, even though they often appear to be.

count creates multiple instances by numeric index:

resource "azurerm_resource_group" "example" {
count = 3
name = "rg-example-${count.index}"
location = "eastus"
}

Those instances are addressed like this:

azurerm_resource_group.example[0]
azurerm_resource_group.example[1]
azurerm_resource_group.example[2]

for_each creates multiple instances by stable key:

resource "azurerm_resource_group" "example" {
for_each = {
dev = "eastus"
test = "eastus2"
prod = "centralus"
}
name = "rg-${each.key}"
location = each.value
}

Those instances are addressed like this:

azurerm_resource_group.example["dev"]
azurerm_resource_group.example["test"]
azurerm_resource_group.example["prod"]

That difference matters. A numeric index describes position. A key describes identity.

Dynamic blocks are different again. They do not create multiple resources. They generate repeated nested blocks inside a resource, data source, provider, or provisioner block. Terraform’s own documentation describes dynamic blocks as a way to construct repeated nested block structures, not as a replacement for top-level resource iteration.  

That gives us a practical rule of thumb:

Use count when instances are truly interchangeable and order-based.

Use for_each when each instance has a stable identity.

Use dynamic blocks when the provider schema requires repeatable nested blocks inside one resource.

For Azure work, that usually means for_each should be your default for real infrastructure objects like resource groups, virtual networks, subnets, route tables, private DNS zones, managed identities, and role assignments.

Where count Gets Dangerous: The Index-Shift Problem

The classic count problem happens when a resource is created from a list and the list changes in the middle.

Here is a familiar Azure example:

variable "subnet_names" {
type = list(string)
default = ["web", "app", "data"]
}
resource "azurerm_resource_group" "network" {
name = "rg-network-dev"
location = "eastus"
}
resource "azurerm_virtual_network" "main" {
name = "vnet-dev-eastus"
location = azurerm_resource_group.network.location
resource_group_name = azurerm_resource_group.network.name
address_space = ["10.20.0.0/16"]
}
resource "azurerm_subnet" "subnet" {
count = length(var.subnet_names)
name = var.subnet_names[count.index]
resource_group_name = azurerm_resource_group.network.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.20.${count.index}.0/24"]
}

Terraform tracks these subnets by index:

azurerm_subnet.subnet[0] = web
azurerm_subnet.subnet[1] = app
azurerm_subnet.subnet[2] = data

Now remove "app":

default = ["web", "data"]

Terraform now sees:

azurerm_subnet.subnet[0] = web
azurerm_subnet.subnet[1] = data

But previously index 1 meant app. Depending on the resource arguments and provider behavior, Terraform may plan to update, replace, or destroy resources because the identity of index 1 changed. That is the index-shift problem.

This is especially dangerous when the repeated resources are not disposable. Azure subnets, route tables, public IPs, private endpoints, role assignments, and managed identities usually have relationships with other resources. Replacing them can cascade into bigger changes.

The safer version uses a map:

variable "subnets" {
type = map(object({
address_prefix = string
}))
default = {
web = {
address_prefix = "10.20.0.0/24"
}
app = {
address_prefix = "10.20.1.0/24"
}
data = {
address_prefix = "10.20.2.0/24"
}
}
}
resource "azurerm_subnet" "subnet" {
for_each = var.subnets
name = each.key
resource_group_name = azurerm_resource_group.network.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = [each.value.address_prefix]
}

Now Terraform tracks identity by key:

azurerm_subnet.subnet["web"]
azurerm_subnet.subnet["app"]
azurerm_subnet.subnet["data"]

Remove "app" and Terraform removes only:

azurerm_subnet.subnet["app"]

No shifting. No accidental identity swap. No surprise conga line of infrastructure changes.

When count Is Still Fine

count is not bad. It is just commonly overused.

It works well for simple on/off creation:

variable "enable_diagnostics_storage" {
type = bool
default = true
}
resource "azurerm_storage_account" "diagnostics" {
count = var.enable_diagnostics_storage ? 1 : 0
name = "stdiagdev001"
resource_group_name = azurerm_resource_group.network.name
location = azurerm_resource_group.network.location
account_tier = "Standard"
account_replication_type = "LRS"
}

This pattern is reasonable because there is only one possible instance: index 0. There is no middle element to remove and no meaningful identity problem.

count is also acceptable for homogeneous, disposable resources where the individual instances have no long-term identity. Even then, be careful. Cloud resources have a habit of becoming important five minutes after you assumed they were temporary.

for_each Wants Known Keys Before Apply

for_each solves identity problems, but it comes with one very important rule: Terraform must know the complete set of keys during planning. HashiCorp’s documentation states that for_each accepts a map or set, but its value must be known before Terraform performs remote resource actions. It cannot depend on resource attributes that are only known after apply, such as IDs generated by a remote API.  

This fails:

resource "azurerm_resource_group" "apps" {
for_each = toset(["api", "worker"])
name = "rg-${each.key}-dev"
location = "eastus"
}
resource "azurerm_user_assigned_identity" "app" {
for_each = azurerm_resource_group.apps
name = "id-${each.value.id}"
location = each.value.location
resource_group_name = each.value.name
}

The mistake here is subtle. We are using a resource-derived object as the basis for for_each. Even when some attributes are known, this kind of pattern often drifts toward relying on computed values.

The safer pattern is to derive the keys from input variables or locals and then reference resource attributes inside the resource body.

locals {
apps = {
api = {
resource_group_name = "rg-api-dev"
location = "eastus"
}
worker = {
resource_group_name = "rg-worker-dev"
location = "eastus"
}
}
}
resource "azurerm_resource_group" "apps" {
for_each = local.apps
name = each.value.resource_group_name
location = each.value.location
}
resource "azurerm_user_assigned_identity" "app" {
for_each = local.apps
name = "id-${each.key}-dev"
location = azurerm_resource_group.apps[each.key].location
resource_group_name = azurerm_resource_group.apps[each.key].name
}

The important part is this:

for_each = local.apps

The keys are known before apply:

api
worker

The resource group names and locations can be referenced inside the block because Terraform already knows how many identity resources it must plan and what their addresses will be.

Handling Computed Values With for_each

A common Azure example is role assignments. You may want to create role assignments for managed identities, but identity principal IDs are not known until Azure creates the identities.

This does not mean for_each is impossible. It means the keys must be known, while the values inside the resource can be computed.

locals {
apps = {
api = {
role_definition_name = "Storage Blob Data Contributor"
}
worker = {
role_definition_name = "Storage Queue Data Contributor"
}
}
}
resource "azurerm_resource_group" "main" {
name = "rg-apps-dev"
location = "eastus"
}
resource "azurerm_storage_account" "app" {
name = "stappsdev001"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
account_tier = "Standard"
account_replication_type = "LRS"
}
resource "azurerm_user_assigned_identity" "app" {
for_each = local.apps
name = "id-${each.key}-dev"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
}
resource "azurerm_role_assignment" "storage" {
for_each = local.apps
scope = azurerm_storage_account.app.id
role_definition_name = each.value.role_definition_name
principal_id = azurerm_user_assigned_identity.app[each.key].principal_id
}

The principal_id is computed by Azure, but that is okay. It is not being used to decide the number of azurerm_role_assignmentinstances. The for_each keys come from local.apps, which Terraform can know during planning.

The pattern to remember is:

for_each = known_map
computed_argument = resource.example[each.key].computed_attribute

Avoid this pattern:

for_each = {
for identity in azurerm_user_assigned_identity.app :
identity.principal_id => identity
}

That uses computed principal IDs as keys. Terraform cannot safely plan that because the instance addresses would not be known until after apply.

When you hit this problem, you usually have four options.

First, redesign the input so stable keys come from configuration. Names like apiworkerbilling, or reporting are usually better keys than Azure-generated IDs.

Second, chain resources using the same known keyspace. Create resource groups, identities, role assignments, diagnostic settings, and private endpoints from the same map where appropriate.

Third, split the workflow into phases only when absolutely necessary. Terraform may suggest targeting dependent resources first, but -target should be treated as an escape hatch, not a normal design pattern.

Fourth, use data sources only when the looked-up objects already exist independently of the current apply. A data source can help when you are integrating with pre-existing infrastructure, but it does not magically make newly created apply-time values available during planning.

Why Stable Keys Are Infrastructure Design

Choosing for_each keys feels like a Terraform syntax decision, but it is really a design decision.

Bad keys are things that change:

for_each = {
"10.20.1.0/24" = { name = "app" }
}

That looks clever until you need to resize the subnet. Now the Terraform address changes just because the CIDR changed.

Better:

for_each = {
app = {
address_prefix = "10.20.1.0/24"
}
}

The key should represent the thing’s identity, not one of its editable properties.

Good Azure for_each keys often look like this:

locals {
subnets = {
web = { address_prefix = "10.20.0.0/24" }
app = { address_prefix = "10.20.1.0/24" }
data = { address_prefix = "10.20.2.0/24" }
firewall = { address_prefix = "10.20.10.0/24" }
}
}

The names are stable. The values can evolve.

Dynamic Blocks: Helpful Tool or Readability Trap?

Dynamic blocks are most useful when a resource has repeatable nested blocks and you want to generate those blocks from structured input.

Azure Network Security Groups are a good example. The azurerm_network_security_group resource supports nested security_ruleblocks. If you have a small, fixed set of rules, writing them directly is often clearer. But if you are building a reusable module and accepting a variable number of rules, a dynamic block can be helpful.

variable "security_rules" {
type = map(object({
priority = number
direction = string
access = string
protocol = string
source_port_range = string
destination_port_range = string
source_address_prefix = string
destination_address_prefix = string
}))
default = {
allow_https = {
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "Internet"
destination_address_prefix = "*"
}
}
}
resource "azurerm_network_security_group" "web" {
name = "nsg-web-dev"
location = azurerm_resource_group.network.location
resource_group_name = azurerm_resource_group.network.name
dynamic "security_rule" {
for_each = var.security_rules
content {
name = security_rule.key
priority = security_rule.value.priority
direction = security_rule.value.direction
access = security_rule.value.access
protocol = security_rule.value.protocol
source_port_range = security_rule.value.source_port_range
destination_port_range = security_rule.value.destination_port_range
source_address_prefix = security_rule.value.source_address_prefix
destination_address_prefix = security_rule.value.destination_address_prefix
}
}
}

That is a reasonable use of dynamic. The nested block is repetitive. The input is naturally a collection. The resulting code avoids duplicating a long block shape over and over.

But dynamic blocks can hurt readability when they hide simple configuration behind too much abstraction.

For example, this may be clearer:

resource "azurerm_network_security_group" "web" {
name = "nsg-web-dev"
location = azurerm_resource_group.network.location
resource_group_name = azurerm_resource_group.network.name
security_rule {
name = "allow-https"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "Internet"
destination_address_prefix = "*"
}
}

There is no award for turning five lines of obvious infrastructure into a miniature programming language.

Dynamic Blocks vs. Separate Resources

With AzureRM, you often have a choice: use nested blocks inside a parent resource, or manage child resources separately.

For Network Security Groups, you can define inline security_rule blocks in azurerm_network_security_group, or you can use separate azurerm_network_security_rule resources.

Inline dynamic block:

resource "azurerm_network_security_group" "web" {
name = "nsg-web-dev"
location = azurerm_resource_group.network.location
resource_group_name = azurerm_resource_group.network.name
dynamic "security_rule" {
for_each = var.security_rules
content {
name = security_rule.key
priority = security_rule.value.priority
direction = security_rule.value.direction
access = security_rule.value.access
protocol = security_rule.value.protocol
source_port_range = security_rule.value.source_port_range
destination_port_range = security_rule.value.destination_port_range
source_address_prefix = security_rule.value.source_address_prefix
destination_address_prefix = security_rule.value.destination_address_prefix
}
}
}

Separate resource with for_each:

resource "azurerm_network_security_group" "web" {
name = "nsg-web-dev"
location = azurerm_resource_group.network.location
resource_group_name = azurerm_resource_group.network.name
}
resource "azurerm_network_security_rule" "web" {
for_each = var.security_rules
name = each.key
priority = each.value.priority
direction = each.value.direction
access = each.value.access
protocol = each.value.protocol
source_port_range = each.value.source_port_range
destination_port_range = each.value.destination_port_range
source_address_prefix = each.value.source_address_prefix
destination_address_prefix = each.value.destination_address_prefix
resource_group_name = azurerm_resource_group.network.name
network_security_group_name = azurerm_network_security_group.web.name
}

The separate resource version is usually easier to inspect, target, import, move, and debug. Each rule has its own Terraform address:

azurerm_network_security_rule.web["allow_https"]

With an inline dynamic block, the generated nested blocks live inside the parent resource. That can make plans more compact, but it can also make drift and targeted troubleshooting less pleasant.

The tradeoff is simple: dynamic blocks reduce code repetition, while separate resources improve addressability.

For small modules, inline dynamic blocks are often fine. For large production environments where individual nested items are managed, imported, moved, or audited independently, separate resources with for_each are often the better long-term bet.

Debugging Tradeoffs With Dynamic Blocks

Dynamic blocks add an extra mental step. You are no longer reading the final resource shape directly. You are reading code that generates the final resource shape.

That matters during debugging.

With for_each resources, Terraform plans show resource addresses by key:

azurerm_subnet.subnet["app"]
azurerm_subnet.subnet["data"]

That is easy to reason about.

With dynamic blocks, the generated nested blocks appear as part of the parent resource diff. If the input transformation is complicated, you may have to jump between variables, locals, and the dynamic block to understand what Terraform is actually producing.

A good practice is to normalize dynamic block input in locals before using it.

locals {
normalized_security_rules = {
for name, rule in var.security_rules : name => {
priority = rule.priority
direction = upper(rule.direction)
access = title(lower(rule.access))
protocol = title(lower(rule.protocol))
source_port_range = try(rule.source_port_range, "*")
destination_port_range = rule.destination_port_range
source_address_prefix = try(rule.source_address_prefix, "*")
destination_address_prefix = try(rule.destination_address_prefix, "*")
}
}
}

Then use the normalized local:

dynamic "security_rule" {
for_each = local.normalized_security_rules
content {
name = security_rule.key
priority = security_rule.value.priority
direction = security_rule.value.direction
access = security_rule.value.access
protocol = security_rule.value.protocol
source_port_range = security_rule.value.source_port_range
destination_port_range = security_rule.value.destination_port_range
source_address_prefix = security_rule.value.source_address_prefix
destination_address_prefix = security_rule.value.destination_address_prefix
}
}

This makes the dynamic block boring, which is exactly what we want. Interesting logic belongs in one place. The resource block should be as readable as possible.

Refactoring From count to for_each Without Recreating Azure Resources

Now for the important part: how do we move from count to for_each safely?

Terraform tracks resources by address. Changing this:

azurerm_subnet.subnet[0]

to this:

azurerm_subnet.subnet["web"]

is an address change. Without guidance, Terraform may think the old object should be destroyed and a new one should be created.

Modern Terraform supports moved blocks for this kind of refactoring. HashiCorp documents moved blocks as a way to preserve existing objects when refactoring resource or module addresses, including changes involving count and for_each.  

Start with the original count resource:

variable "subnet_names" {
type = list(string)
default = ["web", "app", "data"]
}
resource "azurerm_subnet" "subnet" {
count = length(var.subnet_names)
name = var.subnet_names[count.index]
resource_group_name = azurerm_resource_group.network.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.20.${count.index}.0/24"]
}

Convert the input to a map:

variable "subnets" {
type = map(object({
address_prefix = string
}))
default = {
web = {
address_prefix = "10.20.0.0/24"
}
app = {
address_prefix = "10.20.1.0/24"
}
data = {
address_prefix = "10.20.2.0/24"
}
}
}

Update the resource:

resource "azurerm_subnet" "subnet" {
for_each = var.subnets
name = each.key
resource_group_name = azurerm_resource_group.network.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = [each.value.address_prefix]
}

Then add explicit moved blocks:

moved {
from = azurerm_subnet.subnet[0]
to = azurerm_subnet.subnet["web"]
}
moved {
from = azurerm_subnet.subnet[1]
to = azurerm_subnet.subnet["app"]
}
moved {
from = azurerm_subnet.subnet[2]
to = azurerm_subnet.subnet["data"]
}

Now run:

terraform plan

What you want to see is Terraform recognizing that the state addresses moved, not that Azure subnets will be destroyed and recreated.

Do not skip the plan review. The moved block handles state address refactoring, but it does not excuse accidental argument changes. If you also changed names, address prefixes, service endpoints, delegations, or policies, Terraform may still plan real infrastructure changes for those reasons.

A Safer Refactoring Workflow

For production Azure environments, refactor in small steps.

First, capture the current state addresses:

terraform state list | grep azurerm_subnet.subnet

You might see:

azurerm_subnet.subnet[0]
azurerm_subnet.subnet[1]
azurerm_subnet.subnet[2]

Then inspect the current instances:

terraform state show 'azurerm_subnet.subnet[0]'
terraform state show 'azurerm_subnet.subnet[1]'
terraform state show 'azurerm_subnet.subnet[2]'

Map each index to its future key:

azurerm_subnet.subnet[0] -> azurerm_subnet.subnet["web"]
azurerm_subnet.subnet[1] -> azurerm_subnet.subnet["app"]
azurerm_subnet.subnet[2] -> azurerm_subnet.subnet["data"]

Add the moved blocks and change the resource to for_each.

Then run:

terraform plan

You are looking for move messages and no unexpected delete/create operations.

After the apply succeeds and your team has moved past the refactor, you can usually leave moved blocks in place for a while. They are harmless documentation for the migration and useful for teammates or automation that may not have applied every intermediate version yet.

Refactoring With terraform state mv

Before moved blocks existed, many teams used terraform state mv for this sort of migration:

terraform state mv \
'azurerm_subnet.subnet[0]' \
'azurerm_subnet.subnet["web"]'

That still works, and it can be useful in specialized recovery scenarios. But for normal code refactoring, moved blocks are usually better because they are declarative, reviewable, and committed alongside the code change.

A state command is an operation someone ran once. A moved block is part of the refactor story.

That matters when you work on a team, use CI/CD, or maintain long-lived Terraform modules.

Common Mistakes to Avoid

The first mistake is using list position as identity. Lists are fine for values where order matters, but infrastructure objects usually deserve names. If removing one element from the middle of a list changes the meaning of everything after it, that list should probably be a map.

The second mistake is using computed values as for_each keys. Azure resource IDs, principal IDs, generated names, and API-returned values are often unknown during planning. Use stable configuration keys instead.

The third mistake is reaching for dynamic blocks too early. If there are only one or two nested blocks and they rarely change, static HCL may be easier for the next person to read.

The fourth mistake is combining refactors with behavior changes. Moving from count to for_each is already a meaningful change. Do not also rename everything, change CIDR ranges, reorganize modules, and upgrade providers in the same pull request unless you enjoy archaeological debugging.

The fifth mistake is assuming moved blocks prevent all changes. They only tell Terraform that an object’s address changed. They do not suppress real differences in configuration.

Practical Guidance for AzureRM Modules

For AzureRM modules, I generally recommend this pattern:

Use maps of objects for named infrastructure.

variable "subnets" {
type = map(object({
address_prefixes = list(string)
}))
}

Use the same keyspace across related resources.

resource "azurerm_subnet" "this" {
for_each = var.subnets
name = each.key
resource_group_name = var.resource_group_name
virtual_network_name = var.virtual_network_name
address_prefixes = each.value.address_prefixes
}
resource "azurerm_network_security_group" "this" {
for_each = var.subnets
name = "nsg-${each.key}"
location = var.location
resource_group_name = var.resource_group_name
}
resource "azurerm_subnet_network_security_group_association" "this" {
for_each = var.subnets
subnet_id = azurerm_subnet.this[each.key].id
network_security_group_id = azurerm_network_security_group.this[each.key].id
}

This is clean, stable, and easy to reason about. The subnet, NSG, and association all share the same key. Computed Azure IDs are used as arguments, not as instance keys.

Use dynamic blocks when they improve the module interface, not just because they look clever.

variable "delegations" {
type = map(object({
service_name = string
actions = list(string)
}))
default = {}
}
resource "azurerm_subnet" "this" {
name = "snet-app"
resource_group_name = azurerm_resource_group.network.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = ["10.20.1.0/24"]
dynamic "delegation" {
for_each = var.delegations
content {
name = delegation.key
service_delegation {
name = delegation.value.service_name
actions = delegation.value.actions
}
}
}
}

That is a good fit because subnet delegation is a nested block, and not every subnet needs the same delegation configuration.

The Mental Model That Keeps You Safe

Think of Terraform addresses as the contract between your code and your state file.

With count, the contract says:

This thing is number 0.
This thing is number 1.
This thing is number 2.

With for_each, the contract says:

This thing is web.
This thing is app.
This thing is data.

Humans think in names. Cloud platforms think in IDs. Terraform state needs stable addresses. for_each gives you a practical bridge between those worlds.

Dynamic blocks are not about identity. They are about generating nested configuration. That is useful, but it is not a substitute for designing stable resource addresses.

Conclusion: Prefer Identity Over Position

countfor_each, and dynamic blocks are all useful Terraform tools. The trick is knowing which problem you are solving.

Use count for simple conditional resources and truly interchangeable instances. Use for_each for named infrastructure where stable identity matters. Use dynamic blocks for repeatable nested blocks when they make the configuration clearer instead of more mysterious.

When migrating from count to for_each, do it deliberately. Map old indexes to new keys, add moved blocks, run a careful plan, and keep the refactor separate from unrelated infrastructure changes.

Key takeaways:

  • count can cause dangerous index-shift problems when list elements are removed or reordered.
  • for_each avoids index shifting by tracking resources with stable keys.
  • for_each keys must be known during planning; computed Azure values can be used inside the resource body, but not as instance keys.
  • Dynamic blocks are best for repeatable nested blocks, not top-level resource identity.
  • Use moved blocks to refactor existing resources from count to for_each without destroying and recreating them.
  • In AzureRM modules, maps of objects plus stable keys usually produce the safest long-term design.

What Terraform refactor made you sweat the most: moving from count to for_each, splitting resources into modules, or untangling dynamic blocks that got a little too “creative”?

Related Articles

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.