Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Terraform for_each vs count Explained

By Kokil Thapa | Last reviewed: September 2026

You open a Terraform plan and three unrelated resources want to be destroyed because someone reordered a list. That pain is exactly why Terraform for_each vs count Explained matters on every real project. Both meta-arguments let you create multiple resource instances from one block, but they address state differently and fail in different ways. If you treat infrastructure as code with Terraform as production software, the choice between them is not stylistic. It controls whether a variable edit renames a server or quietly replaces the wrong one.

What is the difference between Terraform for_each and count?

Both are meta-arguments on a resource, module, or data source. They tell Terraform how many instances to create from a single configuration block. The syntax looks similar. The state addressing model is not.

count takes a whole number. Terraform creates instances numbered from zero upward: aws_instance.web[0], aws_instance.web[1], and so on. The index is the identity.

for_each takes a map or a set of strings. Each instance gets a string key: aws_instance.web["prod"], aws_instance.web["staging"]. The key is the identity.

State Address Identitycount = 3web[0] web[1] web[2]Index = identityfor_each mapweb["prod"] web["staging"]Key = identityReorder list with countSame index, different resource = destroy + createReorder map keys with for_each = no address change
Terraform for_each vs count explained: count binds identity to list position; for_each binds it to a stable string key.

On production VPS stacks I maintain with Deployer and GitLab CI, Terraform usually runs before the app lands. A bad iteration choice shows up as surprise downtime during a routine variable edit. That is the practical gap between the two options.

Minimal count example

variable "subnet_cidrs" {
  type    = list(string)
  default = ["10.0.1.0/24", "10.0.2.0/24"]
}

resource "aws_subnet" "private" {
  count             = length(var.subnet_cidrs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.subnet_cidrs[count.index]
  availability_zone = element(["ap-south-1a", "ap-south-1b"], count.index)

  tags = {
    Name = "private-${count.index}"
  }
}

Minimal for_each example

variable "subnets" {
  type = map(object({
    cidr = string
    az   = string
  }))
  default = {
    app = { cidr = "10.0.1.0/24", az = "ap-south-1a" }
    db  = { cidr = "10.0.2.0/24", az = "ap-south-1b" }
  }
}

resource "aws_subnet" "private" {
  for_each          = var.subnets
  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value.cidr
  availability_zone = each.value.az

  tags = {
    Name = "private-${each.key}"
  }
}

Inside the block, count.index is a number. With for_each, you use each.key and each.value. Those names are reserved. Do not declare variables with those names in the same module.

When should you use Terraform for_each instead of count?

Default to for_each whenever instances represent named things: environments, regions, tenants, or DNS records keyed by hostname. If removing one key should destroy exactly that instance and leave the rest untouched, you want a map or set key.

I reach for for_each on almost every multi-environment module. Sister sites on shared EC2 infrastructure each get a stable key like notary-nepal or court-marriage. Reordering the input map never shuffles which server Terraform thinks belongs to which name.

  1. Input is a map or can become one with explicit keys.
  2. Each instance has a business meaning beyond its position in a list.
  3. You expect keys to be added or removed independently over time.
  4. You need to pass a subset to a child module via for_each on modules.
  5. You want terraform state mv to rename keys without replacement when possible.

Convert a list to a keyed map safely

Teams often receive a plain list from an API or legacy config. Do not feed that list directly to count if order can change. Build a map first.

variable "server_names" {
  type    = list(string)
  default = ["web-01", "web-02", "web-03"]
}

locals {
  servers = { for name in var.server_names : name => name }
}

resource "digitalocean_droplet" "web" {
  for_each = local.servers
  name     = each.key
  region   = "blr1"
  size     = "s-1vcpu-1gb"
  image    = "ubuntu-24-04-x64"
}

The list order no longer defines identity. The server name does. That pattern appears constantly in reusable Terraform modules shared across client projects.

for_each vs count DecisionNeed multiple instances?Enable or disable one thing?Use countcount = condition ? 1 : 0Use for_eachMap or set of string keysAvoid count on lists that reorder — use for_each with stable keysSee HashiCorp docs on for_each and count meta-arguments
Decision tree for Terraform for_each vs count: toggles use count; named instances use for_each with stable keys.

When is Terraform count the right choice?

count still earns its place. The two legitimate patterns are conditional creation and fixed-size replica groups where index identity is acceptable.

Pattern 1: feature flag as zero or one resource

variable "enable_nat_gateway" {
  type    = bool
  default = true
}

resource "aws_nat_gateway" "main" {
  count         = var.enable_nat_gateway ? 1 : 0
  allocation_id = aws_eip.nat[0].id
  subnet_id     = aws_subnet.public[0].id
}

Writing for_each = var.enable_nat_gateway ? { main = true } : {} works too. Many teams prefer count here because the intent reads clearly: one optional resource, not a collection.

Pattern 2: identical replicas with no stable name

Three identical worker nodes behind a load balancer may not need individual keys. If losing node 1 and gaining a new node 1 is acceptable, count is fine. Document that index shifts cause replacement.

Do not use count for DNS records, IAM bindings keyed by username, or anything referenced elsewhere by index. Those references break silently when the list changes. Related reading: Terraform dynamic blocks in practice for nested iteration inside a single resource.

How do for_each and count affect Terraform state and plans?

State stores each instance at a unique address. That address is how Terraform matches configuration to real infrastructure on the next plan. Change the address without a proper state mv, and Terraform plans a destroy plus create.

Consider a count-based list ["alpha", "beta", "gamma"]. Index 0 is alpha. You remove alpha from the list. Beta slides to index 0. Terraform now thinks index 0 should be beta. It destroys the old index-0 alpha resource and creates a new index-0 beta resource. Beta was already running at index 1. You get an unnecessary replacement.

With for_each on { alpha = "...", beta = "...", gamma = "..." }, removing alpha destroys only ["alpha"]. Beta and gamma addresses stay put.

Plan Impact: Remove First Itemcount — remove list[0][0] alpha → destroy[1] beta → moves to [0][0] beta → replace (wrong!)Cascade of unwanted changesfor_each — remove key["alpha"] → destroy only["beta"] → unchanged["gamma"] → unchangedSurgical plan, safe applyProtect production with remote state + plan reviewStore state in S3, GCS, or Terraform CloudRun terraform plan in CI before apply
Removing the first list element with count reshuffles indexes; for_each removes only the targeted key address.

Remote state is non-negotiable for teams. Local state on a laptop cannot survive handoffs. See manage Terraform state safely and remote backend configuration for locking and bucket setup.

Referencing instances from other blocks

# count reference — fragile if count changes
resource "aws_route53_record" "web" {
  count   = length(var.server_names)
  zone_id = aws_route53_zone.main.zone_id
  name    = var.server_names[count.index]
  type    = "A"
  records = [aws_instance.web[count.index].private_ip]
}

# for_each reference — stable
resource "aws_route53_record" "web" {
  for_each = local.servers
  zone_id  = aws_route53_zone.main.zone_id
  name     = each.key
  type     = "A"
  records  = [aws_instance.web[each.key].private_ip]
}

Splat expressions differ too. aws_instance.web[*].id returns a list for count. For for_each, use values(aws_instance.web)[*].id or a comprehension. Mixing the two styles in one module causes confusing type errors at plan time.

How do you migrate from count to for_each without downtime?

Refactoring live infrastructure requires state surgery. Never run terraform apply on a blind conversion and hope Terraform deduplicates correctly. It will not.

The safe sequence:

  1. Change HCL from count to for_each with keys that match your naming intent.
  2. Run terraform plan and confirm Terraform wants mass destroy/create.
  3. For each instance, run terraform state mv 'aws_instance.web[0]' 'aws_instance.web["web-01"]'.
  4. Re-run plan until only in-place updates remain.
  5. Apply during a maintenance window if any replacement still appears.
# Example state moves after converting count to for_each
terraform state mv 'aws_subnet.private[0]' 'aws_subnet.private["app"]'
terraform state mv 'aws_subnet.private[1]' 'aws_subnet.private["db"]'

# Verify
terraform plan

Back up state before moving addresses. Copy the remote state object in S3 or export with terraform state pull > backup.tfstate. Pair this with prevent_destroy lifecycle rules on critical resources during migration. Details sit in Terraform lifecycle meta-arguments explained.

CI pipelines should run terraform plan on every pull request. A refactor that skips state moves shows up as a red plan with dozens of replacements. Tools like Checkov catch misconfigurations but not address drift. See Checkov scan for Terraform misconfigurations and Terraform CI/CD with GitHub Actions.

Modules accept both meta-arguments

Calling a module with for_each creates multiple module instances in state. Each gets its own output namespace. This pattern scales well in Terragrunt DRY layouts where each site or region is a keyed module instance.

module "site" {
  for_each = var.sites
  source   = "./modules/laravel-vps"

  domain       = each.value.domain
  php_version  = "8.3"
  db_engine    = "mysql"
}

You cannot use both count and for_each on the same block. Terraform rejects it at validation. Pick one per resource, module, or data source.

What are the common mistakes with for_each and count?

These show up repeatedly in code review and production incident postmortems.

  • Using count on a list sourced from sort() or an API with unstable ordering.
  • Passing a list of objects to for_each — it requires a map or set of strings, not a list.
  • Using non-string keys in maps passed to for_each — keys must be strings; use tostring() if needed.
  • Referencing each.key inside a block that uses count, or vice versa.
  • Assuming terraform apply -target fixes a bad iteration model — it only limits scope.
  • Duplicating keys in a map comprehension — Terraform errors on duplicate keys at plan time.

Validate inputs early with variable validation blocks. Catch empty maps before they produce zero resources silently.

variable "environments" {
  type = map(object({
    instance_type = string
    cidr          = string
  }))

  validation {
    condition     = length(var.environments) > 0
    error_message = "At least one environment key is required."
  }
}

For debugging complex expressions, paste intermediate locals output into a JSON formatter during development. Run terraform console and evaluate local.servers before applying.

Production Iteration Pipelinevariables.tfmap keysfor_eachresource blockterraform planCI reviewremote stateS3 + lockGotcha: count index shift on list editFix: state mv or switch to for_each keysSame pattern on Laravel VPS stacks before Deployer deployTerraform provisions; app deploy is separate stage
Production workflow for Terraform for_each vs count: keyed variables, CI plan review, and locked remote state before apply.

Official reference material from HashiCorp covers edge cases: the for_each meta-argument documentation, the count meta-argument documentation, and the for expressions guide for building maps from lists.

Criteriafor_eachcount
Accepted input typesMap or set of stringsNon-negative integer (often length(list))
State address formatresource.name["key"]resource.name[index]
Identity stability on list reorderStable if keys unchangedUnstable — index is identity
Best for optional single resourcePossible but verboseIdeal: count = bool ? 1 : 0
Best for named multi-instanceDefault choiceAvoid unless index OK
Iterator variableseach.key, each.valuecount.index
Module iterationStrong fit for keyed environmentsWorks for N identical copies
Refactor cost from wrong choiceLow if keys were always stableHigh — often needs state mv per index

Verdict: Use for_each as the default for any collection where instance identity matters. Reserve count for boolean toggles and homogeneous replicas where index-based replacement is acceptable. That single habit prevents most accidental production replacements.

Context from related posts helps round out the picture. Variables, locals, and outputs feed the maps you pass to for_each. Workspaces and environments solve a different problem — do not confuse workspace name with instance key. If you evaluate OpenTofu as a fork, iteration behaviour matches Terraform; see Terraform vs Pulumi vs OpenTofu. For first VPS provisioning, start with Terraform for VPS provisioning before layering complex iteration.

On the application side, the same identity problem appears in Laravel queue design. A cron job vs queue worker choice also hinges on whether work items have stable identity. Infrastructure and app layers reward the same thinking.

When Terraform runs on Linux servers I administer for clients, PHP version pins and Linux system administration sit downstream of correct provisioning. A booking platform like Adventure Third Pole Trek needs stable DNS and VPS names across deploys. Wrong iteration causes churn that no application code can fix.

Pin provider versions while refactoring. A provider upgrade plus address migration in one PR is painful to debug. Follow Terraform provider version pinning and keep HCL refactors separate from provider bumps.

Key Takeaways

  • Default to for_each with map or set keys whenever instances have meaningful names.
  • Use count only for condition ? 1 : 0 toggles or index-identical replica groups.
  • List reorder with count changes state addresses and triggers unwanted destroy/create cycles.
  • Migrate live resources with terraform state mv, not blind apply, after converting count to for_each.
  • Validate map inputs and run terraform plan in CI before every apply to catch address drift early.
  • Reference instances by key with each.key, never by index, when other resources depend on them.

People Also Ask

Can you use for_each and count on the same resource?

No. Terraform allows exactly one iteration meta-argument per resource, module, or data source block. Choose for_each or count, not both. Attempting both produces a configuration error during terraform validate.

Does for_each work with a list in Terraform?

Not directly. for_each requires a map or a set of strings. Convert a list with a comprehension: { for x in var.list : x => x } when values are unique strings, or assign explicit keys in a locals block.

What happens if you change a for_each key?

Renaming a key changes the state address. Terraform plans to destroy the old address and create the new one unless you run terraform state mv 'resource["old"]' 'resource["new"]'. Treat key names as part of your API contract.

Is count deprecated in Terraform?

No. HashiCorp still documents and supports count. It is the right tool for optional single resources and simple numeric replication. The community default shifted toward for_each for named collections because it produces safer plans.

Pick the right meta-argument on your next module

Terraform for_each vs count Explained boils down to one question: does each instance have a stable name that should survive edits to the input collection? If yes, use for_each. If you only need zero or one of something, use count. Getting this wrong costs more than a long plan diff. It costs downtime on infrastructure your application depends on.

If you are provisioning servers, wiring CI, or refactoring modules across environments and want a second pair of eyes on the state moves, contact us or review custom software and infrastructure development services. Solid iteration choices today save emergency state mv sessions tomorrow.

Frequently Asked Questions

Both are meta-arguments that create multiple instances from one resource, module, or data source block. count takes a non-negative integer and addresses instances by index, such as aws_instance.web[0]. for_each takes a map or set of strings and addresses instances by string key, such as aws_instance.web["prod"]. Inside the block, count uses count.index while for_each uses each.key and each.value. The syntax looks similar, but the state addressing model is not. count binds identity to list position; for_each binds it to a stable string key you control.

Default to for_each whenever instances represent named things: environments, regions, tenants, or DNS records keyed by hostname. Use it when input is a map or can become one with explicit keys, when each instance has business meaning beyond its position, when keys are added or removed independently, when passing keyed subsets to child modules, or when you want terraform state mv to rename keys without replacement when possible. On multi-environment modules I maintain for sister sites on shared EC2 infrastructure, stable keys like notary-nepal survive map reordering without shuffling which server belongs to which name.

count earns its place in two legitimate patterns. First, conditional creation as a feature flag: count = var.enable_nat_gateway ? 1 : 0 reads clearly for one optional resource. Second, fixed-size replica groups where index identity is acceptable, such as three identical worker nodes behind a load balancer where losing node 1 and gaining a new node 1 is fine. Do not use count for DNS records, IAM bindings keyed by username, or anything referenced elsewhere by index. Those references break silently when the list changes.

No. Terraform allows exactly one iteration meta-argument per resource, module, or data source block. Attempting both produces a configuration error during terraform validate.

Not directly. for_each requires a map or a set of strings. Convert a list with a comprehension like { for name in var.server_names : name => name } in a locals block so list order no longer defines identity.

State stores each instance at a unique address that Terraform uses to match configuration to real infrastructure on the next plan. Change the address without a proper state mv and Terraform plans a destroy plus create. With count on a list, removing the first element reshuffles indexes and can destroy the wrong instance while one already running gets replaced unnecessarily. With for_each, removing a key destroys only that keyed address while others stay put. Remote state with locking is non-negotiable for teams because local state on a laptop cannot survive handoffs.

Consider a count-based list of alpha, beta, and gamma where index 0 is alpha. Remove alpha and beta slides to index 0. Terraform now thinks index 0 should be beta. It destroys the old index-0 alpha resource and creates a new index-0 beta resource, even though beta was already running at index 1. You get an unnecessary replacement. This is the pain that shows up when someone reorders a list during a routine variable edit. for_each on a keyed map avoids this because beta and gamma addresses stay put when alpha is removed.

Never run terraform apply on a blind conversion. Change HCL from count to for_each with keys matching your naming intent, run terraform plan and confirm mass destroy/create appears, then run terraform state mv for each instance such as moving aws_subnet.private[0] to aws_subnet.private["app"]. Re-run plan until only in-place updates remain, then apply during a maintenance window if any replacement still appears. Back up state first by copying the remote state object in S3 or exporting with terraform state pull. Pair this with prevent_destroy lifecycle rules on critical resources during migration.

Teams often receive a plain list from an API or legacy config. Do not feed that list directly to count if order can change. Build a map in locals with a comprehension: { for name in var.server_names : name => name }. Pass that map to for_each so the server name defines identity, not list position. That pattern appears constantly in reusable Terraform modules shared across client projects. Validate inputs early with variable validation blocks to catch empty maps before they produce zero resources silently.

With count, referencing aws_instance.web[count.index] is fragile if count changes. With for_each, reference aws_instance.web[each.key] for stable links, such as pairing Route53 records by hostname key. Splat expressions differ too: aws_instance.web[].id returns a list for count, while for for_each use values(aws_instance.web)[].id or a comprehension. Mixing the two styles in one module causes confusing type errors at plan time. When other resources depend on instances, reference by key with each.key, never by index.

Repeated production and code-review failures include using count on a list sourced from sort() or an API with unstable ordering, passing a list of objects directly to for_each instead of a map or set of strings, using non-string map keys without tostring(), referencing each.key inside a count block or count.index inside a for_each block, assuming terraform apply -target fixes a bad iteration model, and duplicating keys in a map comprehension which Terraform errors on at plan time. Validate map inputs and run terraform plan in CI before every apply to catch address drift early.

Modules accept both meta-arguments. Calling a module with for_each creates multiple module instances in state, each with its own output namespace. This scales well in Terragrunt DRY layouts where each site or region is a keyed module instance, such as module site with for_each over var.sites pointing to a Laravel VPS module with domain and php_version per key. You cannot use both count and for_each on the same block. Terraform rejects it at validation, so pick one per resource, module, or data source.

Use for_each as the default for any collection where instance identity matters. Reserve count for boolean toggles written as condition ? 1 : 0 and homogeneous replicas where index-based replacement is acceptable. Named multi-instance resources should use for_each with stable keys. Optional single resources are ideal for count. Refactor cost from choosing count wrongly is high and often needs state mv per index, while for_each refactor cost stays low if keys were always stable. That single habit prevents most accidental production replacements.

Renaming a for_each key changes the state address, so Terraform treats it as a destroy of the old key and a create of the new one unless you use terraform state mv to rename the address without touching real infrastructure. The same rule applies to count indexes: change the address without state surgery and Terraform plans destroy plus create. That is why stable string keys matter for named infrastructure like DNS records and environment-specific VPS instances you provision before application deploys land.

CI pipelines should run terraform plan on every pull request. A refactor that skips state moves shows up as a red plan with dozens of replacements. Tools like Checkov catch misconfigurations but not address drift from wrong iteration choices. Pin provider versions while refactoring and keep HCL refactors separate from provider bumps, because a provider upgrade plus address migration in one pull request is painful to debug. Production workflow means keyed variables, CI plan review, and locked remote state before apply.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: