Terraform in Production · Changes That Don't Destroy Things

Removing One Tenant Recreated Another

count addresses resources by position, so removing an item from the middle of a list shifts every index after it. for_each uses stable keys.

Scenario

Example team: IAM users, SQS queues and S3 buckets for each tenant are created from a list variable with count. Offboarding a tenant means deleting their name from the list.

What you see in production

Offboarding tenant ben from ["ana", "ben", "cho"] produces "1 to add, 2 to destroy". Besides ben's queue, the plan destroys cho's live queue (dropping every message in it) and creates an empty one in its place. The reviewer expected "1 to destroy".

The naive code

variable "tenants" {
  type    = list(string)
  default = ["ana", "ben", "cho"]
}

resource "aws_sqs_queue" "tenant" {
  count = length(var.tenants)
  name  = "orders-${var.tenants[count.index]}"   # name change forces a new queue
}

Reproduce it

The same shape with terraform_data, whose triggers_replace behaves like a force-new attribute (a queue or bucket name). The first config is applied, then ben is removed:

count: removing the middle item
variable "tenants" {
  default = ["ana", "ben", "cho"]
}
resource "terraform_data" "tenant" {
  count            = length(var.tenants)
  triggers_replace = var.tenants[count.index]
}
# after: remove "ben"
variable "tenants" {
  default = ["ana", "cho"]
}
resource "terraform_data" "tenant" {
  count            = length(var.tenants)
  triggers_replace = var.tenants[count.index]
}

Output

Terraform will perform the following actions:

  # terraform_data.tenant[1] must be replaced
-/+ resource "terraform_data" "tenant" {
      ~ id               = "1851f1a5-2b48-a27f-27c2-317bf4096d6f" -> (known after apply)
      ~ triggers_replace = "ben" -> "cho"
    }

  # terraform_data.tenant[2] will be destroyed
  # (because index [2] is out of range for count)
  - resource "terraform_data" "tenant" {
      - id               = "8040c7e5-d182-0b8d-682e-5645dca6ca78" -> null
      - triggers_replace = "cho" -> null
    }

Plan: 1 to add, 0 to change, 2 to destroy.

Run this on your computer — see “Get Started” for setup.

Root cause

With count, instances are identified by index: tenant[0], tenant[1], tenant[2]. After removing ben, cho moves from index 2 to index 1, so Terraform sees tenant[1]'s name change from ben to cho (a replacement) and tenant[2] out of range (a destroy). Net effect: ben's queue goes, as intended, but cho's real queue at index 2 is destroyed and a new, empty cho queue is created at index 1. With a longer list, every item after the removed one is recreated. Identity follows position, not meaning.

The fix

Key instances by something stable with for_each:

for_each: only the removed key is destroyed
variable "tenants" {
  default = ["ana", "ben", "cho"]
}
resource "terraform_data" "tenant" {
  for_each         = toset(var.tenants)
  triggers_replace = each.key
}
# after: remove "ben"
variable "tenants" {
  default = ["ana", "cho"]
}
resource "terraform_data" "tenant" {
  for_each         = toset(var.tenants)
  triggers_replace = each.key
}

Output

Terraform will perform the following actions:

  # terraform_data.tenant["ben"] will be destroyed
  # (because key ["ben"] is not in for_each map)
  - resource "terraform_data" "tenant" {
      - id               = "abe6efa5-af76-cdb2-2a3e-de49d4962101" -> null
      - triggers_replace = "ben" -> null
    }

Plan: 0 to add, 0 to change, 1 to destroy.

Run this on your computer — see “Get Started” for setup.

Migrating existing count resources to for_each without recreating them takes one moved block per instance:

resource "aws_sqs_queue" "tenant" {
  for_each = toset(var.tenants)
  name     = "orders-${each.key}"
}

moved {
  from = aws_sqs_queue.tenant[0]
  to   = aws_sqs_queue.tenant["ana"]
}
moved {
  from = aws_sqs_queue.tenant[1]
  to   = aws_sqs_queue.tenant["ben"]
}
moved {
  from = aws_sqs_queue.tenant[2]
  to   = aws_sqs_queue.tenant["cho"]
}

Trade-offs

  • for_each keys must be known at plan time; you can't key on an ID that is created during the same apply.
  • count is still right for "N identical things" (count = var.enable_nat ? 1 : 0, or three identical worker nodes).
  • Use a map (for_each = var.tenants with objects) when each instance needs its own settings.

Detect it in production

  • Fail CI when a plan replaces or deletes more than the change intends (see "Guard Rails for Destructive Plans").
  • Lint for count = length(var.…) over lists of names; it is almost always a for_each waiting to happen.

Key points

  • count = identity by position; removing from the middle shifts and replaces everything after it.
  • for_each = identity by key; only the removed key is destroyed.
  • Move existing instances to for_each with moved blocks so nothing is recreated.

Practice questions

Why does removing the middle item of a `count`-based list cause replacements?

Answer

Instances are addressed by index. Every item after the removed one shifts down an index, so Terraform sees changed arguments on those indexes (replacing them if the argument forces a new resource) and destroys the last index.

When is `count` still the better choice over `for_each`?

Answer

For a conditional single resource (count = var.enabled ? 1 : 0) or N truly identical instances where position doesn't matter.

How do you switch an existing `count` resource to `for_each` without recreating it?

Answer

Change the configuration to for_each and add a moved block per instance mapping each old index to its new key. The plan should then show only moves.

Open in the app to mark lessons complete, take notes and track your progress.