Skip to main content
RunBook Academy

TerraformVII · Resources, Data Sources, and count/for_eachProduction Terraform

count: Index-Based Resource Instances

Intermediate⏱ ~12 minbash

What you'll learn

  • Use count to create multiple resource instances from a list or count expression
  • Identify the index-shift anti-pattern in count-based resources
  • Reference count.index correctly across dependent resources
  • Recognise when to migrate from count to for_each for stable identity

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

A team needs three public subnets in three availability zones. They write count = 3 and reference the subnets as aws_subnet.public[0], [1], [2]. Six months later they add a fourth AZ. The plan shows destroys and recreates for subnets 1, 2, and 3. The state file remembers each subnet by index, and the indices have shifted. This lesson is the operational discipline of index-based resources.

The count meta-argument

count accepts a non-negative integer. Terraform creates that many instances of the resource. Each instance is identified by its index (0 to count - 1). Inside the block, count.index is the current instance’s index.

resource "aws_subnet" "public" {
  count = 3

  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet("10.0.0.0/16", 8, count.index)
  availability_zone = data.aws_availability_zones.available.names[count.index]

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

Three subnets are created. Their CIDR blocks are 10.0.0.0/24, 10.0.1.0/24, 10.0.2.0/24. Their AZs are the first three in the region. The Name tags are public-0, public-1, public-2.

Conditional creation

count is also used to enable or disable a resource based on a condition:

resource "aws_nat_gateway" "main" {
  count = var.enable_nat_gateway ? 1 : 0

  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[0].id

  tags = {
    Name = "nat-${var.environment}"
  }
}

When var.enable_nat_gateway is false, count = 0 and the resource is not created. The state has no entry for it. References to aws_nat_gateway.main[0] error if the count is 0; wrap them in [0] only when the count is known to be 1.

The index-shift anti-pattern

The most common production failure mode for count is the index-shift. Adding to or reordering the list of indices shifts every instance after the change. Terraform destroys the old instance at that index and creates a new one with the new attribute.

Before: count = ["a", "b", "c"]
  aws_subnet.public[0] -> cidr 10.0.1.0/24, az "a"
  aws_subnet.public[1] -> cidr 10.0.2.0/24, az "b"
  aws_subnet.public[2] -> cidr 10.0.3.0/24, az "c"

Edit:   count = ["a", "x", "b", "c"]   (insert "x" at index 1)
  aws_subnet.public[0] -> cidr 10.0.1.0/24, az "a"   (unchanged)
  aws_subnet.public[1] -> cidr 10.0.x.0/24, az "x"   (new)
  aws_subnet.public[2] -> cidr 10.0.b.0/24, az "b"   (re-created from old [1])
  aws_subnet.public[3] -> cidr 10.0.c.0/24, az "c"   (re-created from old [2])

The subnets at indices 1 and 2 are destroyed and recreated. Their identifiers (subnet IDs) change. Any resource that referenced them by index is now pointing at the wrong subnet. Any tag, route, or IAM policy that used the old subnet ID is broken.

The same trap exists when:

  • A list is reordered by sorting or deduplication.
  • A new element is prepended rather than appended.
  • A list filter removes an element from the middle.

When count is the right tool

count is appropriate when:

  • The instances are homogeneous (no per-instance identity beyond the index).
  • The instances are disposable (the cost of replacement is acceptable).
  • The list is append-only (no insertions in the middle).
  • The instances are conditionally enabled (count = 0 or 1).

Typical use cases:

  • A NAT gateway enabled per environment.
  • A bastion host with a count of 1 or 2.
  • A set of read replicas where ordering does not matter.

count is the wrong tool when:

  • Instances have stable identifiers (hostnames, IDs, AZs) that should not change when the list changes.
  • Instances must persist across insertions in the middle of the list.
  • You need to reference instances by name rather than index.

For those cases, use for_each.

Migrating from count to for_each

The migration is mechanical but not automatic. Terraform cannot infer that aws_subnet.public[0] should become aws_subnet.public["a"]. The state file must be moved.

# Before: count-based
terraform state mv 'aws_subnet.public[0]' 'aws_subnet.public["a"]'
terraform state mv 'aws_subnet.public[1]' 'aws_subnet.public["b"]'
terraform state mv 'aws_subnet.public[2]' 'aws_subnet.public["c"]'

After the move, the configuration is changed to use for_each with a map of keys. The plan should be empty after the migration; the state file should reference the same instances under their new addresses.

Production failure modes

  1. Inserting into the middle of a count list. The plan shows destroys for resources at and after the insertion index. The apply tears them down. Symptom: terraform plan shows -/+ for resources that the team did not intend to replace. Recovery: migrate to for_each with stable keys, or move the insertion to the end of the list.

  2. Reordering a count list. A sort() or distinct() is added to the list expression. The plan shows destroys and recreates. Symptom: the order of items in plan output changes; every instance after the first reordering is replaced. Recovery: remove the reorder; if a stable order is required, switch to for_each.

  3. count.index in tags. A team uses Name = "public-${count.index}". When the list is re-indexed, every tag changes. Symptom: the plan shows tag changes for every instance, even though the instances themselves are unchanged. Recovery: switch to a stable name from a map; the Name tag should not be positional.

  4. Conditional resources that toggle. A resource has count = var.enable ? 1 : 0. The variable flips between 0 and 1 over the lifetime of the configuration. Every flip destroys and recreates the resource. Symptom: the resource is replaced on every toggle. Recovery: if persistence across toggles is required, use a separate resource without count.

  5. Cross-resource references to count.index. Resource A references resource B as aws_b.x[0]. Resource B is reordered. Resource A now points at a different instance. Symptom: the apply succeeds but the dependency is wrong; the configuration describes a graph that does not match reality. Recovery: switch both to for_each with the same key.

  6. count = 0 masking a real bug. A count = 0 is left in the configuration to suppress an unwanted resource. The underlying dependency is not cleaned up. Symptom: the configuration applies but the dependency is silently removed. Recovery: remove the entire resource block; let the next apply destroy the resource properly.

Security and performance

Security. Resources created by count share the same IAM permissions, security groups, and network policies as their template. There is no per-instance permission boundary. If you need per-instance isolation, model each instance as a separate resource or as for_each over a map that includes the policy.

Performance. count does not parallelise: the apply phase creates instances sequentially unless the dependencies allow parallelism. For large counts, the apply can take a long time. Most providers rate-limit per account per region, so a count = 100 can hit API limits.

What to do in production

  • Prefer for_each over count whenever instances have stable identifiers (names, AZs, roles).
  • Use count only for genuinely homogeneous, disposable, or conditional resources.
  • Avoid count.index in resource tags and DNS names. It couples identity to position.
  • Always terraform plan and review the diff before changing a list that drives a count.
  • For migrations from count to for_each, take a state backup (terraform state pull > backup.tfstate) before any terraform state mv.

Verification

# 1. List count-based resources
grep -rE 'count\s*=' .

# 2. Confirm the count matches expectations
terraform plan -out=tfplan
terraform show -json tfplan | jq '.resource_changes[] | select(.change.actions | length == 1) | .address'

# 3. Inspect the addresses of the count instances
terraform state list | grep aws_subnet.public

# 4. Simulate a change to the count and verify the plan
terraform plan -var='subnet_count=4' -out=tfplan-4
terraform show -json tfplan-4 | jq '.resource_changes[].change.actions'

# 5. Confirm no unintended replacements
terraform plan  # should show only the intended change

A clean verification looks like:

$ terraform plan -var='subnet_count=4' -out=tfplan-4
$ terraform show -json tfplan-4 | jq '.resource_changes[].change.actions'
[
  "create"
]

One new subnet. No destroys.

Knowledge check · 7 questions

  1. Q1. What does `count.index` represent inside a count-based resource block?

  2. Q2. A configuration has `count = 3` and a list `["a", "b", "c"]` driving the AZ selection. An engineer inserts `"x"` at the start of the list, making it `["x", "a", "b", "c"]` and adjusts `count = 4`. What is the effect on the existing instances?

  3. Q3. `for_each` is the right tool when instances have stable identifiers like names or AZs.

  4. Q4. Which of the following are appropriate uses of `count`? (Select all that apply.)

  5. Q5. What command is used to migrate a count-based resource to for_each without losing state?

  6. Q6. A team has three subnets created with `count = 3` and `cidrsubnet(var.vpc_cidr, 8, count.index)`. They add a fourth AZ to their variable list. The plan shows `-/+` for subnets 1, 2, and 3. What is the most likely cause?

  7. Q7. Why is `count.index` in a Name tag a code smell?

Passing score: 75%. Answers are checked in this browser.