Skip to main content
RunBook Academy

← All labs in Terraform

Lab · intermediate · ~45 min

Lab: count vs for_each for Resource Instances

C · Simulation

Objectives

  • Reproduce the count index shift: prove that removing one list element makes Terraform destroy two resources and replace a third
  • Read a plan by resource address rather than by the summary line, using terraform show -json
  • Show that for_each keyed identity confines the same edit to exactly one destroy
  • Migrate a live count resource to for_each with moved blocks and prove the plan is empty
  • Explain why the naive count-to-for_each edit plans 3 destroys and 3 creates

Prerequisites

Objective

By the end of this lab you will have made Terraform destroy a resource nobody asked it to destroy, by deleting one name from the middle of a list. You will have the plan output that proves it, the same experiment run against for_each for contrast, and a moved.tf that carries a live count configuration across to for_each with a plan of 0 to add, 0 to change, 0 to destroy.

The point is not that for_each is better. You already read that in the lesson. The point is to see the shape of the damage in plan output, so that you recognise it in a pull request at 17:40 on a Friday.

Architecture

Two directories, one resource type, three names. The two configurations differ in exactly three lines.

count-vs-foreach/
├── count/
│   ├── main.tf                 count = length(var.services)
│   ├── services.auto.tfvars    services = ["alpha", "bravo", "charlie"]
│   └── moved.tf                (written in Task 7)
└── foreach/
    ├── main.tf                 for_each = toset(var.services)
    └── services.auto.tfvars    services = ["alpha", "bravo", "charlie"]

Each configuration writes one file per service into its own directory: svc-alpha.conf, svc-bravo.conf, svc-charlie.conf. The resources are files because files are cheap, local, and instantly inspectable. Read every local_file in this lab as a database instance, a DNS record, or a VM — something whose destruction is a change-freeze conversation rather than a ls.

Requirements

  • A Linux or macOS workstation with shell access and a writable $HOME.
  • Terraform 1.9.x or later. Every output below was captured on Terraform v1.9.8 with hashicorp/local v2.9.0.
  • jq, used to read plan JSON. terraform show -json output is one very long line without it.
  • Outbound HTTPS to registry.terraform.io for the first terraform init in each directory. After that the lab is offline.
  • No cloud account, no credentials, no cost. Nothing outside the two lab directories is written.

Confirm the version before you start — the moved block in Task 7 needs Terraform 1.1 or later, and the plan wording below is 1.9.x:

terraform version

Scenario

A platform team manages per-service configuration with one Terraform resource and a list of service names. The list has grown to eleven entries over two years. This sprint, bravo is decommissioned, and the change is a one-line diff: delete "bravo" from the list.

The reviewer approves it in forty seconds — it is a deletion of one service, the diff is one line, and the plan summary at the bottom of the CI comment is short. The apply takes down charlie as well.

Nobody made a mistake. The configuration did exactly what it says. Your job is to reproduce that, and to make the same one-line diff safe.

Tasks

Task 1: Scaffold both configurations

LAB="$HOME/count-vs-foreach"
mkdir -p "$LAB/count" "$LAB/foreach"
cd "$LAB/count"

Write main.tf:

terraform {
  required_version = ">= 1.9.0"

  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.5"
    }
  }
}

variable "services" {
  description = "One config file is created for each service in this list."
  type        = list(string)
}

resource "local_file" "service" {
  count = length(var.services)

  filename = "${path.module}/svc-${var.services[count.index]}.conf"
  content  = "service = ${var.services[count.index]}\n"
}

Write services.auto.tfvars beside it. The .auto.tfvars suffix means Terraform loads it without a -var-file flag, which is what keeps every command in this lab identical between the two directories:

services = ["alpha", "bravo", "charlie"]

Now produce the for_each variant. It is the same file with three lines changed, and generating it with sed rather than retyping it is the point — any behavioural difference you see later cannot be a typo somewhere else:

cd "$HOME/count-vs-foreach/count"

sed -e 's/  count = length(var.services)/  for_each = toset(var.services)/' \
    -e 's/var\.services\[count\.index\]/each.key/g' \
    main.tf > ../foreach/main.tf

cp services.auto.tfvars ../foreach/
diff main.tf ../foreach/main.tf

diff should report exactly three changed lines: the meta-argument, the filename, and the content.

Task 2: Apply the count configuration

cd "$HOME/count-vs-foreach/count"
terraform init
terraform plan -out=tfplan

The plan reports Plan: 3 to add, 0 to change, 0 to destroy. Apply the saved plan file rather than re-planning — applying a plan you have already read is the habit this course wants, and it means no confirmation prompt stands between you and an apply you did not review:

Configuration changecount/
$ terraform apply tfplan
local_file.service[1]: Creating...
local_file.service[0]: Creating...
local_file.service[2]: Creating...
local_file.service[1]: Creation complete after 0s [id=351111a3680c108731c65b1d9cd559262cce7360]
local_file.service[0]: Creation complete after 0s [id=1bf0ed5ea7ba5124378abbf616f2e202a55b5636]
local_file.service[2]: Creation complete after 0s [id=78960e60a63de1af536f2ea4df319b6c5bbbd2a4]

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Task 3: Record which address holds which service

This mapping is the whole lab. Write it down; you are going to compare against it twice.

Read-only / Safecount/
$ terraform show -json | jq -r '.values.root_module.resources[] | "\(.address)  ->  \(.values.filename)"'
local_file.service[0]  ->  ./svc-alpha.conf
local_file.service[1]  ->  ./svc-bravo.conf
local_file.service[2]  ->  ./svc-charlie.conf

Three addresses, three services, in list order. Nothing in the state records that index 1 means bravo. The state records that index 1 currently has a filename of ./svc-bravo.conf, which is a different claim, and the difference is the entire failure mode.

Task 4: Predict, then plan the decommission

Edit services.auto.tfvars to remove bravo — the one-line diff from the scenario:

services = ["alpha", "charlie"]

Before running anything, write down your answers:

QuestionYour predictionObserved
How many resources will be destroyed?
How many will be created?
Which addresses are touched?

Now plan:

terraform plan -out=tfplan
Read-only / Safecount/
$ terraform show -json tfplan | jq -r '.resource_changes[] | "\(.address)  \(.change.actions | join(","))"'
local_file.service[0]  no-op
local_file.service[1]  delete,create
local_file.service[2]  delete

Two destroys and one recreate, from deleting one name. The human-readable plan explains why. The six content_* hash attributes are noise at this width, so filter them out:

Read-only / Safecount/
$ terraform show -no-color tfplan | grep -v 'content_'
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  - destroy
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # local_file.service[1] must be replaced
-/+ resource "local_file" "service" {
      ~ content              = <<-EOT # forces replacement
          - service = bravo
          + service = charlie
        EOT
      ~ filename             = "./svc-bravo.conf" -> "./svc-charlie.conf" # forces replacement
      ~ id                   = "351111a3680c108731c65b1d9cd559262cce7360" -> (known after apply)
        # (2 unchanged attributes hidden)
    }

  # local_file.service[2] will be destroyed
  # (because index [2] is out of range for count)
  - resource "local_file" "service" {
      - content              = <<-EOT
            service = charlie
        EOT -> null
      - directory_permission = "0777" -> null
      - file_permission      = "0777" -> null
      - filename             = "./svc-charlie.conf" -> null
      - id                   = "78960e60a63de1af536f2ea4df319b6c5bbbd2a4" -> null
    }

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

Read the two comment lines. They are Terraform telling you the truth in full:

  • local_file.service[1] must be replaced — index 1 used to be bravo and is now charlie. filename is a force-new attribute for local_file, so Terraform cannot update it in place. It destroys what is at index 1 and builds a new thing there.
  • local_file.service[2] will be destroyed (because index [2] is out of range for count) — index 2 held charlie. The list is now two long, so index 2 no longer exists, and the resource at it is removed.

charlie was never mentioned in the diff. charlie is destroyed, and also rebuilt at a different address.

Task 5: Apply it and watch the log

Destructivecount/
$ terraform apply tfplan
local_file.service[1]: Destroying... [id=351111a3680c108731c65b1d9cd559262cce7360]
local_file.service[2]: Destroying... [id=78960e60a63de1af536f2ea4df319b6c5bbbd2a4]
local_file.service[2]: Destruction complete after 0s
local_file.service[1]: Destruction complete after 0s
local_file.service[1]: Creating...
local_file.service[1]: Creation complete after 0s [id=78960e60a63de1af536f2ea4df319b6c5bbbd2a4]

Apply complete! Resources: 1 added, 0 changed, 2 destroyed.

The end state on disk is correct — svc-alpha.conf and svc-charlie.conf — and that is what makes this so hard to catch. The declarative outcome is right. The path taken to it destroyed a resource that was meant to survive.

Restore the third service before continuing. Put bravo back in services.auto.tfvars:

services = ["alpha", "bravo", "charlie"]
Destructivecount/
$ terraform plan -out=tfplan && terraform apply tfplan
local_file.service[1]: Destroying... [id=78960e60a63de1af536f2ea4df319b6c5bbbd2a4]
local_file.service[1]: Destruction complete after 0s
local_file.service[2]: Creating...
local_file.service[2]: Creation complete after 0s [id=78960e60a63de1af536f2ea4df319b6c5bbbd2a4]
local_file.service[1]: Creating...
local_file.service[1]: Creation complete after 0s [id=351111a3680c108731c65b1d9cd559262cce7360]

Apply complete! Resources: 2 added, 0 changed, 1 destroyed.

Note what the rollback cost: charlie is destroyed and rebuilt a second time. Reverting the pull request does not undo the damage — it repeats it. That is worth knowing before an incident, because “revert the commit” is the first thing anyone will suggest.

Task 6: The same edit against for_each

cd "$HOME/count-vs-foreach/foreach"
terraform init
terraform plan -out=tfplan
terraform apply tfplan
terraform state list

The state now reads by name, not by position:

local_file.service["alpha"]
local_file.service["bravo"]
local_file.service["charlie"]

Predict again, then make the identical edit to foreach/services.auto.tfvars:

services = ["alpha", "charlie"]
Read-only / Safeforeach/
$ terraform plan -out=tfplan && terraform show -json tfplan | jq -r '.resource_changes[] | "\(.address)  \(.change.actions | join(","))"'
local_file.service["alpha"]  no-op
local_file.service["bravo"]  delete
local_file.service["charlie"]  no-op

Plan: 0 to add, 0 to change, 1 to destroy, and the human-readable plan names the reason in a way no reviewer can misread:

  # local_file.service["bravo"] will be destroyed
  # (because key ["bravo"] is not in for_each map)

Compare that comment with the one from Task 4. because index [2] is out of range for count requires the reader to hold the list in their head to know which service index 2 was. because key ["bravo"] is not in for_each map states the service by name. The difference in review quality is larger than the difference in syntax.

Apply it:

Destructiveforeach/
$ terraform apply tfplan
local_file.service["bravo"]: Destroying... [id=351111a3680c108731c65b1d9cd559262cce7360]
local_file.service["bravo"]: Destruction complete after 0s

Apply complete! Resources: 0 added, 0 changed, 1 destroyed.

One destroy. alpha and charlie were not read, not refreshed into a change, and not touched.

Task 7: Migrate the count configuration without destroying anything

The count directory still has three live resources at integer addresses. This is the position every team that inherits a count configuration is in, and the migration is where the real risk sits — not in the everyday edit.

First, do it the naive way. Edit count/main.tf so the resource block matches the foreach one:

resource "local_file" "service" {
  for_each = toset(var.services)

  filename = "${path.module}/svc-${each.key}.conf"
  content  = "service = ${each.key}\n"
}

Plan it — and do not apply:

Read-only / Safecount/
$ terraform plan -out=tfplan-naive && terraform show -json tfplan-naive | jq -r '.resource_changes[] | "\(.address)  \(.change.actions | join(","))"'
local_file.service[0]  delete
local_file.service[1]  delete
local_file.service[2]  delete
local_file.service["alpha"]  create
local_file.service["bravo"]  create
local_file.service["charlie"]  create

Plan: 3 to add, 0 to change, 3 to destroy. Terraform has no way to know that local_file.service[0] and local_file.service["alpha"] are the same real resource. An address is an address. Applying this rebuilds the entire estate.

rm tfplan-naive

Now do it properly. Add moved.tf in the same directory:

moved {
  from = local_file.service[0]
  to   = local_file.service["alpha"]
}

moved {
  from = local_file.service[1]
  to   = local_file.service["bravo"]
}

moved {
  from = local_file.service[2]
  to   = local_file.service["charlie"]
}

The index-to-key mapping comes from the table you recorded in Task 3. There is no way to derive it after the fact if the configuration has already changed — which is why Task 3 said to write it down.

Read-only / Safecount/
$ terraform plan -out=tfplan
Terraform will perform the following actions:

  # local_file.service[0] has moved to local_file.service["alpha"]
    resource "local_file" "service" {
        id                   = "1bf0ed5ea7ba5124378abbf616f2e202a55b5636"
        # (10 unchanged attributes hidden)
    }

  # local_file.service[1] has moved to local_file.service["bravo"]
    resource "local_file" "service" {
        id                   = "351111a3680c108731c65b1d9cd559262cce7360"
        # (10 unchanged attributes hidden)
    }

  # local_file.service[2] has moved to local_file.service["charlie"]
    resource "local_file" "service" {
        id                   = "78960e60a63de1af536f2ea4df319b6c5bbbd2a4"
        # (10 unchanged attributes hidden)
    }

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

0 to add, 0 to change, 0 to destroy is the only acceptable plan for a refactor. Anything else means the mapping is wrong, and you want to find that out here rather than after the apply.

Configuration changecount/
$ terraform apply tfplan
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Applying a saved plan prints nothing but the summary — the three moves were decided at plan time and are already recorded in tfplan. That is the whole argument for the saved-plan workflow: what you reviewed is what runs.

Confirm the addresses moved and the files never went anywhere:

terraform state list
ls -1 svc-*.conf

Finally, delete moved.tf and plan once more. A moved block whose from no longer exists in state is silently ignored, so leaving it does no harm — but it is a statement about a migration that has already happened, and stale statements accumulate:

rm moved.tf
terraform plan
No changes. Your infrastructure matches the configuration.

Validation

Work through these in order. Each one is a command with a stated result, not a feeling that the lab went well.

  1. In count/, terraform show -json | jq -r '.values.root_module.resources[].address' returns three addresses and every one is quoted-string-keyed (local_file.service["alpha"]), not integer-keyed.
  2. ls -1 svc-*.conf in count/ lists exactly svc-alpha.conf, svc-bravo.conf, svc-charlie.conf.
  3. terraform plan in count/, with moved.tf deleted, reports No changes. Your infrastructure matches the configuration.
  4. Your prediction table from Task 4 records the observed result as 1 create and 2 destroys, and names local_file.service[2] as a destroy you did not ask for.
  5. Your prediction table from Task 6 records 0 creates and 1 destroy, and names local_file.service["bravo"] as the only address touched.
  6. In foreach/, terraform state list returns two addresses, and svc-bravo.conf is absent from the directory.
  7. You can state, without looking it up, why the naive migration in Task 7 planned six changes rather than zero.

Expected Outcome

count-vs-foreach/
├── count/
│   ├── .terraform.lock.hcl
│   ├── main.tf                 for_each, migrated in Task 7
│   ├── services.auto.tfvars    three services
│   ├── svc-alpha.conf
│   ├── svc-bravo.conf
│   ├── svc-charlie.conf
│   ├── terraform.tfstate       three string-keyed addresses
│   └── tfplan
└── foreach/
    ├── .terraform.lock.hcl
    ├── main.tf
    ├── services.auto.tfvars    two services
    ├── svc-alpha.conf
    ├── svc-charlie.conf
    └── terraform.tfstate       two string-keyed addresses

Both directories now key their instances by service name. You have watched an unrequested destroy appear in a plan, you know the two comment lines that announce it, and you have run the migration that prevents it on a configuration that was already live.

Troubleshooting

The given "for_each" argument value is unsuitable. The full error reads:

The given "for_each" argument value is unsuitable: the "for_each" argument
must be a map, or set of strings, and you have provided a value of type list
of string.

for_each does not accept a list, because a list has positions and for_each needs keys. toset(var.services) converts it. If the elements are objects rather than strings, build a map instead with a for expression keyed on a stable field.

toset() silently deduplicated my list. toset(["alpha", "alpha", "bravo"]) is a two-element set. The same list under count = length(var.services) creates three instances. Migrating a count configuration whose list contains duplicates therefore changes the instance count, and the moved mapping will not line up. Check first with terraform console:

echo 'length(toset(var.services)) == length(var.services)' | terraform console

false means you have duplicates to resolve before migrating.

The plan with moved.tf is not 0/0/0. The mapping is wrong. Either an index is paired with the wrong key, or the configuration change and the moved blocks disagree about which keys exist. Recover the true mapping from the state backup — terraform show -json terraform.tfstate.backup — rather than guessing from the file names on disk, which the failed plan may already have changed.

Terraform prompts me for var.services. The tfvars file is not being loaded. It must be named exactly services.auto.tfvars or terraform.tfvars; any other name needs -var-file. A file called services.tfvars is loaded by nobody and produces exactly this prompt.

Error: Invalid single-argument block definition. You compressed the variable block onto one line. HCL allows a single-line block with one argument only; type and default together need the multi-line form.

The moved block errors with an address that is not in the configuration. The to address must correspond to a resource block that exists in the current configuration. Moving to a resource you have deleted is not a move, it is a removal — that is what removed blocks and terraform state rm are for.

I applied the naive migration by mistake. For local_file the cost is three rewritten files. For anything with data behind it, stop the apply, do not re-run, and restore state from terraform.tfstate.backup before deciding anything — Terraform writes that file immediately before each state change.

Cleanup

Everything this lab created lives under one directory. No system configuration was changed, no service was contacted, and no privilege was escalated.

Step 1. Destroy the managed resources in both directories, so state and disk agree before anything is deleted:

Destructivecount/
$ terraform -chdir="$HOME/count-vs-foreach/count" destroy -auto-approve
local_file.service["bravo"]: Destroying... [id=351111a3680c108731c65b1d9cd559262cce7360]
local_file.service["charlie"]: Destroying... [id=78960e60a63de1af536f2ea4df319b6c5bbbd2a4]
local_file.service["alpha"]: Destroying... [id=1bf0ed5ea7ba5124378abbf616f2e202a55b5636]
local_file.service["alpha"]: Destruction complete after 0s
local_file.service["charlie"]: Destruction complete after 0s
local_file.service["bravo"]: Destruction complete after 0s

Destroy complete! Resources: 3 destroyed.

Then the same for the other directory, which holds two:

Destructiveforeach/
$ terraform -chdir="$HOME/count-vs-foreach/foreach" destroy -auto-approve
local_file.service["alpha"]: Destruction complete after 0s
local_file.service["charlie"]: Destruction complete after 0s

Destroy complete! Resources: 2 destroyed.

Step 2. Keep the prediction table — it is the deliverable, and it is the only part of this lab that is yours:

mkdir -p "$HOME/terraform-lab-deliverables"
cp "$HOME/count-vs-foreach/predictions.md" \
   "$HOME/terraform-lab-deliverables/count-vs-foreach-predictions.md"

Step 3. Confirm what you are about to delete, then delete it.

ls -la "$HOME/count-vs-foreach"
rm -rf "$HOME/count-vs-foreach"

The provider plugin cache under ~/.terraform.d/ is shared and is left alone deliberately; removing it only forces the next terraform init anywhere on this machine to re-download.

What You Learned

  • You reproduced the index shift, and the damage was larger than the edit: removing one of three services produced one replacement and two destroys, one of them on a service the change never named.
  • The plan summary line hides which resources are affected. terraform show -json tfplan | jq prints one line per address and is short enough to paste into a review. 2 to destroy and local_file.service[2] delete are the same fact with completely different review outcomes.
  • The two plan comments are the diagnosis. because index [2] is out of range for count and because key ["bravo"] is not in for_each map tell you which identity model you are looking at before you read another line.
  • Reverting does not undo an index shift. Putting bravo back destroyed and rebuilt charlie a second time.
  • The naive count-to-for_each edit plans a full rebuild, because Terraform matches by address and every address changed. Three moved blocks turned that into 0 to add, 0 to change, 0 to destroy.
  • for_each is not free. toset() deduplicates, so a list with repeats produces fewer instances than count did; keys must be strings; and the key you choose becomes an identity you cannot change later without a destroy. for_each moves the failure from “positions shift” to “choose a stable key”, which is a much better problem, but it is still a problem you have to think about once.

Deliverables

  • · A count configuration and a for_each configuration that differ in exactly three lines
  • · A prediction table with your guess and the observed plan for both configurations
  • · A moved.tf that migrates three count instances to string keys, applied with a 0/0/0 plan

Verification status

Last reviewed
2026-08-19
Executed end to end
2026-08-19