Terraform count index shift: removing one list item planned to replace half our queues
We deleted one queue name from a Terraform list and the plan wanted to destroy and recreate every queue after it. count addresses resources by position; we moved to for_each with moved blocks and changed nothing in AWS.
A small cleanup PR removed one decommissioned SQS queue name from a list variable. The reviewer expected the plan to destroy one queue. The plan in the PR comment said this instead:
Plan: 3 to add, 0 to change, 4 to destroy.
Four destroys for one removed name. Three of the queues being destroyed were live, carrying order events for the fulfilment service. Applying it would have dropped every in-flight message in them and briefly broken the consumers that look queues up by URL. Fortunately the pipeline requires a human approval for any plan containing a delete, and the approver stopped it.
Reproducing it
The module looked like this:
variable "queue_names" {
type = list(string)
default = ["orders-created", "orders-legacy-sync", "orders-paid", "orders-shipped", "orders-refunded"]
}
resource "aws_sqs_queue" "this" {
count = length(var.queue_names)
name = var.queue_names[count.index]
visibility_timeout_seconds = 60
}
The PR removed orders-legacy-sync, the second element. The relevant part of the plan:
# aws_sqs_queue.this[1] must be replaced
-/+ resource "aws_sqs_queue" "this" {
~ name = "orders-legacy-sync" -> "orders-paid" # forces replacement
...
}
# aws_sqs_queue.this[2] must be replaced
-/+ resource "aws_sqs_queue" "this" {
~ name = "orders-paid" -> "orders-shipped" # forces replacement
}
# aws_sqs_queue.this[3] must be replaced
-/+ resource "aws_sqs_queue" "this" {
~ name = "orders-shipped" -> "orders-refunded" # forces replacement
}
# aws_sqs_queue.this[4] will be destroyed
# (because index [4] is out of range for count)
What we ruled out
- Provider upgrade side effect. The lock file had not changed, and the same plan appeared with the previous provider version.
- Drift.
terraform plan -refresh-onlyon main showed no changes, so state matched AWS before the PR. - A bug in the queue attributes. Only
namechanged in each replacement, and it changed to the neighbouring queue's name.
Root cause
count gives each instance an integer address: this[0], this[1], and so on. Terraform maps configuration to state by that address, not by the queue name. Removing the second element shifted every later name down by one index, so this[1] now had the name of the old this[2]. SQS queue names cannot be changed in place, so each shifted index became a destroy-and-create, and the last index fell off the end.
What is happening under the hood
Terraform's plan compares, per resource address, what is in state with what the configuration now says. With count, the address is a position in a list. Positions are stable only as long as you append to the end. Insertions or removals anywhere else renumber the tail.
With for_each, the address is a string key: aws_sqs_queue.this["orders-paid"]. Removing one key affects only that key; the others keep their addresses and their state.
For attributes that the provider can update in place (tags, timeouts), a count shift produces a noisy "update" plan that is still dangerous, because each resource gets its neighbour's settings. For attributes marked as forcing replacement, such as an SQS queue name, an S3 bucket name or a subnet CIDR, the shift becomes destructive.
Environment
| Component | Version / value |
|---|---|
| Terraform | 1.10.5 |
| AWS provider | 5.86.0 |
| Backend | S3 with DynamoDB lock table |
| CI | GitHub Actions, plan on PR, apply on merge with manual approval |
| Resource | aws_sqs_queue, standard queues |
Minimal reproduction
mkdir count-shift && cd count-shift
cat > main.tf <<'HCL'
terraform {
required_providers {
null = { source = "hashicorp/null", version = "~> 3.2" }
}
}
variable "names" {
type = list(string)
default = ["a", "b", "c", "d"]
}
resource "null_resource" "q" {
count = length(var.names)
triggers = { name = var.names[count.index] }
}
HCL
terraform init -input=false >/dev/null
terraform apply -auto-approve >/dev/null
terraform plan -var 'names=["a","c","d"]' | tail -1
# Plan: 2 to add, 0 to change, 3 to destroy.
Changing triggers forces replacement on null_resource, so it behaves like a queue name and shows the shift without touching a cloud account.
Who is affected
Anyone using count over a list of things that have identity: users, buckets, queues, DNS records, subnets. count is still fine for "zero or one" toggles (count = var.enabled ? 1 : 0) and for truly interchangeable instances where order means nothing and you only ever change the number.
The fix
We switched the resource to for_each keyed by queue name, and used moved blocks so Terraform knows that each existing index is the same object as the new key. No state surgery, no outage, and the change is reviewable in the PR.
resource "aws_sqs_queue" "this" {
- count = length(var.queue_names)
- name = var.queue_names[count.index]
+ for_each = toset(var.queue_names)
+ name = each.key
visibility_timeout_seconds = 60
}
+
+moved {
+ from = aws_sqs_queue.this[0]
+ to = aws_sqs_queue.this["orders-created"]
+}
+moved {
+ from = aws_sqs_queue.this[1]
+ to = aws_sqs_queue.this["orders-legacy-sync"]
+}
+moved {
+ from = aws_sqs_queue.this[2]
+ to = aws_sqs_queue.this["orders-paid"]
+}
+moved {
+ from = aws_sqs_queue.this[3]
+ to = aws_sqs_queue.this["orders-shipped"]
+}
+moved {
+ from = aws_sqs_queue.this[4]
+ to = aws_sqs_queue.this["orders-refunded"]
+}
We did this in two PRs on purpose:
- Migration PR: convert to
for_eachwith all five names still present. Expected plan:0 to add, 0 to change, 0 to destroy, plus five lines saying each object has moved. - Cleanup PR: remove
orders-legacy-syncfrom the list. Expected plan:1 to destroy, and it is the right one.
Keeping them separate means each plan has exactly one kind of change, which makes review trivial.
Anything that referenced the queues by index had to change too. Outputs became maps:
output "queue_urls" {
value = { for name, q in aws_sqs_queue.this : name => q.url }
}
moved blocks can stay in the code for a while so that every workspace and every engineer's branch picks up the move. We removed them two releases later, after confirming all workspaces had applied.
Alternatives and trade-offs
terraform state mvdoes the same re-addressing imperatively. It works, but it happens outside code review, must be run per workspace, and is easy to get wrong under pressure.movedblocks (Terraform 1.1 and later) are the declarative version.- Keep
countand only ever append. Fragile; the next person to tidy the list alphabetically triggers the same plan. - A map instead of a set. If each queue needs its own settings, use
for_each = var.queueswithmap(object({...})). The keys still provide stable addresses.
One constraint to know: for_each keys must be known at plan time. Names that come from another resource's computed attributes will not work as keys. Use static names from variables, as we did here.
How to test the fix
terraform plan -out=tfplan -detailed-exitcode
echo "exit=$?" # 0 means no changes, 2 means changes present
# No resource may be deleted in the migration PR
terraform show -json tfplan \
| jq -r '.resource_changes[] | select(.change.actions | index("delete")) | .address'
# expected: no output
# The moves are recorded
terraform show -json tfplan \
| jq -r '.resource_changes[] | select(.previous_address != null) | "\(.previous_address) -> \(.address)"'
We do not rely on the exit code alone for the migration PR. The delete query is the check that matters, and the summary line must read 0 to add, 0 to change, 0 to destroy.
Confirming in production
After applying the migration PR, we compared queue identity before and after. SQS queue URLs and ARNs are derived from the name, so we checked the creation timestamp, which would reset on replacement:
for q in orders-created orders-paid orders-shipped orders-refunded; do
url=$(aws sqs get-queue-url --queue-name "$q" --query QueueUrl --output text)
aws sqs get-queue-attributes --queue-url "$url" \
--attribute-names CreatedTimestamp ApproximateNumberOfMessages \
--query "Attributes" --output text
done
The timestamps matched the original creation dates from months earlier, and message counts were unchanged. The fulfilment consumers did not log a single queue-not-found error.
Regression guard
Two guards now run on every plan in CI. First, a policy step fails the job and requires an explicit label on the PR if any resource is deleted or replaced:
deletes=$(terraform show -json tfplan | jq '[.resource_changes[] | select(.change.actions | index("delete"))] | length')
if [ "$deletes" -gt 0 ] && [ "${ALLOW_DESTROY:-false}" != "true" ]; then
echo "Plan deletes $deletes resources; add the allow-destroy label after review."
exit 1
fi
Second, a lint rule in our module review checklist (enforced with a TFLint custom ruleset) flags count = length( patterns over lists of strings. Stateful resources also got lifecycle { prevent_destroy = true }, which turns an accidental destroy into a plan error.
Security implications
The bug itself was an availability and data-loss risk: replacing a queue drops unconsumed messages, and a dead-letter queue relationship pointing at a recreated queue breaks silently until the redrive policy is reapplied. There is also a quieter security angle. Recreated resources come back with whatever the configuration says today, which may differ from manual hardening applied in the console (an access policy edited by hand, for example). Replacement can silently widen or narrow access. The migration itself changes no permissions; moved only changes addresses in state.
Prevention checklist
- Use
for_eachwith stable string keys for anything that has an identity. - Reserve
countfor on/off toggles and truly interchangeable instances. - Migrate with
movedblocks in their own PR, expecting a zero-change plan. - Fail CI on deletes or replacements unless a reviewer explicitly allows them.
- Add
prevent_destroyto stateful resources such as queues, buckets and databases.
References
- Terraform docs, the
countmeta-argument: https://developer.hashicorp.com/terraform/language/meta-arguments/count - Terraform docs, the
for_eachmeta-argument: https://developer.hashicorp.com/terraform/language/meta-arguments/for_each - Terraform docs, refactoring with
movedblocks: https://developer.hashicorp.com/terraform/language/modules/develop/refactoring - Terraform docs, JSON output format: https://developer.hashicorp.com/terraform/internals/json-format
- AWS provider,
aws_sqs_queue: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sqs_queue
Add a label
Embed this post
<iframe src="https://www.hibug.me/terraform-count-index-shift-destroys-resources" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a Senior Software Engineer with over a decade of hands-on experience building, scaling, and maintaining complex systems across the full stack—spanning frontend, backend, and DevOps. Over the last 10 years, I have navigated diverse architectures and untangled some of the most frustrating, elusive bugs in production. I believe that every production incident is a massive learning opportunity. Having discovered hibug.me, I am excited to contribute to this knowledge base, breaking down root causes, and sharing verified fixes to help fellow developers debug faster and build more resilient systems.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.