TerraformVII · Resources, Data Sources, and count/for_eachProduction Terraform
count: Index-Based Resource Instances
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
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
-
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 planshows-/+for resources that the team did not intend to replace. Recovery: migrate tofor_eachwith stable keys, or move the insertion to the end of the list. -
Reordering a count list. A
sort()ordistinct()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 tofor_each. -
count.indexin tags. A team usesName = "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. -
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 withoutcount. -
Cross-resource references to
count.index. Resource A references resource B asaws_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 tofor_eachwith the same key. -
count = 0masking a real bug. Acount = 0is 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_eachovercountwhenever instances have stable identifiers (names, AZs, roles). - Use
countonly for genuinely homogeneous, disposable, or conditional resources. - Avoid
count.indexin resource tags and DNS names. It couples identity to position. - Always
terraform planand review the diff before changing a list that drives acount. - For migrations from
counttofor_each, take a state backup (terraform state pull > backup.tfstate) before anyterraform 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
Q1. What does `count.index` represent inside a count-based resource block?
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?
Q3. `for_each` is the right tool when instances have stable identifiers like names or AZs.
Q4. Which of the following are appropriate uses of `count`? (Select all that apply.)
Q5. What command is used to migrate a count-based resource to for_each without losing state?
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?
Q7. Why is `count.index` in a Name tag a code smell?
Passing score: 75%. Answers are checked in this browser.