TerraformVII · Resources, Data Sources, and count/for_eachProduction Terraform
for_each: Stable Key-Based Resource Instances
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
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
| Property | count | for_each |
|---|---|---|
| Identity | Integer index | String key |
| Reference | [0], [1], [2] | ["a"], ["b"], ["c"] |
| Insertion in the middle | Shifts all later indices | Removes/keeps by key, no shift |
| Removal from the middle | Destroys and recreates later items | Destroys only the removed key |
| Readable in plan output | Index numbers | Key strings |
| Schema | Single integer | Map 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:
- Update the configuration to use
for_eachwith a map of keys. - Move each instance in state to the new address.
- 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
-
Passing a list to
for_each. The configuration supplies a list instead of a map or set. Symptom: the plan fails with “thefor_eachvalue must be a map or a set”. Recovery: convert withtoset()or wrap in aforexpression that produces a map. -
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.
-
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. -
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” oneach.key.foo. Recovery: avoid dots in keys, or use bracket indexing (each.key["vpc.a"]). -
Migration breaking cross-references. A
countresource is migrated tofor_eachand the references in other resources are not updated. Symptom: the dependent resource fails to parse becauseaws_subnet.public[0]no longer exists. Recovery: update all references to use string keys; useterraform state mvto relocate the instances. -
Drift in a for_each map. A key is removed from the map but the underlying infrastructure is still in use. Symptom:
terraform planproposes 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_eachovercountwhenever 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 anyterraform state mv. - For very large maps (thousands of keys), break the configuration into multiple files or modules to keep apply times manageable.
- Use
each.keyin 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
Q1. What types of values can `for_each` accept?
Q2. Inside a `for_each` block, what does `each.key` refer to?
Q3. Removing a key from a for_each map destroys only that instance.
Q4. Which of the following are valid migrations from count to for_each? (Select all that apply.)
Q5. What is the reference syntax for a for_each resource instance with key `"a"`?
Q6. A team uses `for_each` over a map whose keys are CIDR blocks. They change one CIDR. What is the effect?
Q7. Why is `for_each` preferred over `count` for production resource collections?
Passing score: 75%. Answers are checked in this browser.