Skip to main content
RunBook Academy

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

for_each: Stable Key-Based Resource Instances

Intermediate⏱ ~12 minbash

What you'll learn

  • Use for_each with both sets and maps for stable resource identity
  • Reference for_each instances correctly using key access
  • Distinguish for_each from count for production use
  • Migrate an existing count resource to for_each without losing state

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 manages six subnets in three AZs. With count, every time they add an AZ the indices shift and the subnets rebuild. With for_each, the subnets are keyed by a stable name; removing one does not disturb the others. This lesson is the operational discipline of key-based resource identity.

The for_each meta-argument

for_each accepts a map or a set. Terraform creates one instance per element. The instance is identified by its key (or set element). Inside the block, each.key and each.value are available.

resource "aws_subnet" "public" {
  for_each = {
    "a" = { cidr = "10.0.1.0/24", az = "eu-west-1a" }
    "b" = { cidr = "10.0.2.0/24", az = "eu-west-1b" }
    "c" = { cidr = "10.0.3.0/24", az = "eu-west-1c" }
  }

  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value.cidr
  availability_zone = each.value.az

  tags = {
    Name = "public-${each.key}"
  }
}

Three instances are created: aws_subnet.public["a"], aws_subnet.public["b"], aws_subnet.public["c"]. Removing key "b" from the map destroys only the subnet "b"; the other two are untouched.

Set form

When the per-instance attributes can be derived from a single value (or are all the same), use a set:

resource "aws_route53_zone" "service" {
  for_each = toset(["api.example.com", "web.example.com"])

  name = each.value
}

The set elements become the keys. each.value is the same as each.key for sets.

Map form

When each instance has its own attributes, use a map:

locals {
  databases = {
    "users"   = { engine = "postgres", version = "16.3", instance_class = "db.r6g.large" }
    "orders"  = { engine = "postgres", version = "16.3", instance_class = "db.r6g.xlarge" }
    "events"  = { engine = "mysql",    version = "8.0", instance_class = "db.r6g.large" }
  }
}

resource "aws_db_instance" "service" {
  for_each = local.databases

  identifier        = each.key
  engine            = each.value.engine
  engine_version    = each.value.version
  instance_class    = each.value.instance_class
  allocated_storage = 100
  storage_type      = "gp3"
}

Each instance is identified by each.key (a string). The full address is aws_db_instance.service["users"], etc.

References

for_each resources expose a map. Other resources can iterate over the map:

resource "aws_route_table_association" "public" {
  for_each = aws_subnet.public

  subnet_id      = each.value.id
  route_table_id = aws_route_table.public.id
}

Or select a single instance:

resource "aws_instance" "bastion" {
  ami           = data.aws_ami.ubuntu.id
  subnet_id     = aws_subnet.public["a"].id
  instance_type = "t3.micro"
}

The map index access (["a"]) works on any for_each resource.

count versus for_each

Propertycountfor_each
IdentityInteger indexString key
Reference[0], [1], [2]["a"], ["b"], ["c"]
Insertion in the middleShifts all later indicesRemoves/keeps by key, no shift
Removal from the middleDestroys and recreates later itemsDestroys only the removed key
Readable in plan outputIndex numbersKey strings
SchemaSingle integerMap or set

The rule of thumb: for_each is the right default unless you have a specific reason to use count (conditional enable/disable, homogeneous disposable instances).

Migrating from count to for_each

The migration requires three steps:

  1. Update the configuration to use for_each with a map of keys.
  2. Move each instance in state to the new address.
  3. Verify the plan is empty.
# Before
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]
}
# After
resource "aws_subnet" "public" {
  for_each = {
    "a" = { cidr = "10.0.1.0/24", az = data.aws_availability_zones.available.names[0] }
    "b" = { cidr = "10.0.2.0/24", az = data.aws_availability_zones.available.names[1] }
    "c" = { cidr = "10.0.3.0/24", az = data.aws_availability_zones.available.names[2] }
  }

  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value.cidr
  availability_zone = each.value.az
}

Move the state:

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"]'

Verify:

terraform plan  # should be empty

Production failure modes

  1. Passing a list to for_each. The configuration supplies a list instead of a map or set. Symptom: the plan fails with “the for_each value must be a map or a set”. Recovery: convert with toset() or wrap in a for expression that produces a map.

  2. Unstable keys. The map key is derived from a value that changes (e.g., the CIDR block itself). When the CIDR changes, the key changes, and Terraform destroys the old instance and creates a new one. Symptom: every CIDR change triggers a replacement. Recovery: use a stable identifier (a name, an ARN, an AZ) as the key.

  3. Empty map. The map is empty, so no instances are created. References to aws_subnet.public["a"] fail at plan time. Symptom: “key does not exist in this map”. Recovery: check the input variable; add a precondition or default value.

  4. Keys with dots in each.key. A key like "vpc.a" is parsed by Terraform as an attribute access path. Symptom: an error like “Unsupported attribute” on each.key.foo. Recovery: avoid dots in keys, or use bracket indexing (each.key["vpc.a"]).

  5. Migration breaking cross-references. A count resource is migrated to for_each and the references in other resources are not updated. Symptom: the dependent resource fails to parse because aws_subnet.public[0] no longer exists. Recovery: update all references to use string keys; use terraform state mv to relocate the instances.

  6. Drift in a for_each map. A key is removed from the map but the underlying infrastructure is still in use. Symptom: terraform plan proposes destroying the resource at that key; the next apply removes it from the cloud. Recovery: remove the key from the map only when the resource is meant to be destroyed.

Security and performance

Security. for_each resources share the same IAM permissions and network policies as their template. For per-instance isolation, include the policy in the per-key map.

Performance. for_each parallelises during apply when the dependencies allow. Most providers rate-limit per account; a for_each over thousands of keys can hit the limit. Run terraform apply -parallelism=10 or lower for large maps.

What to do in production

  • Prefer for_each over count whenever instances have stable identifiers.
  • Build the map from a stable source: a static block, a Terraform variable, or a remote-state output. Avoid building it from a value that changes.
  • For migrations from count, back up the state before any terraform state mv.
  • For very large maps (thousands of keys), break the configuration into multiple files or modules to keep apply times manageable.
  • Use each.key in tags and DNS names — the key is stable, the tag will not change.

Verification

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

# 2. Confirm the for_each instances
terraform state list | grep aws_subnet.public

# 3. Verify the keys match the map
terraform state list | grep aws_subnet.public | sort

# 4. Plan a removal of a key and verify only that key is affected
terraform plan -var='public_subnet_keys=["a","b"]'
terraform show -json tfplan | jq '.resource_changes[].change.actions'

# 5. Confirm no unintended replacements
terraform plan  # should be empty

A clean verification:

$ terraform plan -var='public_subnet_keys=["a","b"]'
$ terraform show -json tfplan | jq '.resource_changes[].change.actions'
[
  "delete"
]

Only the removed key is destroyed.

Knowledge check · 7 questions

  1. Q1. What types of values can `for_each` accept?

  2. Q2. Inside a `for_each` block, what does `each.key` refer to?

  3. Q3. Removing a key from a for_each map destroys only that instance.

  4. Q4. Which of the following are valid migrations from count to for_each? (Select all that apply.)

  5. Q5. What is the reference syntax for a for_each resource instance with key `"a"`?

  6. Q6. A team uses `for_each` over a map whose keys are CIDR blocks. They change one CIDR. What is the effect?

  7. Q7. Why is `for_each` preferred over `count` for production resource collections?

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