Terraform for_each vs count: When to Use Each in 2026
I’ve made the mistake of using count where I should have used for_each. Most people have. You end up with a Terraform state that looks reasonable until you need to delete one resource from the middle of a list, and then you watch in horror as Terraform announces it’s going to destroy and recreate half your infrastructure.
That pain is avoidable. The choice between count and for_each isn’t just a stylistic preference — it’s a decision that determines how stable your state is over time. Get it wrong early and you’ll be writing moved blocks or doing manual state surgery later.
This post is my honest take on when to use each, with real HCL examples and the gotchas I’ve hit.
The Core Difference
count creates resources indexed by integers. for_each creates resources indexed by strings.
That’s it. That’s the whole difference. But the consequences of that difference are enormous.
When you use count, Terraform tracks your resources as aws_s3_bucket.my_bucket[0], aws_s3_bucket.my_bucket[1], aws_s3_bucket.my_bucket[2]. If you remove the first item from your list, index 0 disappears, everything else shifts down, and Terraform thinks you renamed every resource in the collection.
When you use for_each, Terraform tracks them as aws_s3_bucket.my_bucket["production"], aws_s3_bucket.my_bucket["staging"], aws_s3_bucket.my_bucket["dev"]. Remove "staging", and only the staging bucket gets destroyed. The others don’t care.
That’s why I default to for_each for almost everything. Integer indexes are fragile. String keys are stable.
for_each With Maps
The most common pattern I use is for_each with a variable map. Here’s creating multiple S3 buckets with different configurations:
variable "s3_buckets" {
type = map(object({
versioning_enabled = bool
lifecycle_days = number
}))
default = {
"prod-assets" = {
versioning_enabled = true
lifecycle_days = 90
}
"staging-assets" = {
versioning_enabled = false
lifecycle_days = 30
}
"dev-assets" = {
versioning_enabled = false
lifecycle_days = 7
}
}
}
resource "aws_s3_bucket" "assets" {
for_each = var.s3_buckets
bucket = each.key
}
resource "aws_s3_bucket_versioning" "assets" {
for_each = var.s3_buckets
bucket = aws_s3_bucket.assets[each.key].id
versioning_configuration {
status = each.value.versioning_enabled ? "Enabled" : "Suspended"
}
}
resource "aws_s3_bucket_lifecycle_configuration" "assets" {
for_each = var.s3_buckets
bucket = aws_s3_bucket.assets[each.key].id
rule {
id = "expire-objects"
status = "Enabled"
expiration {
days = each.value.lifecycle_days
}
}
}
each.key is the map key — "prod-assets", "staging-assets", "dev-assets". each.value is the object associated with that key. Clean, readable, and when you need to add a fourth bucket you just add a map entry. No need to touch the resource block.
Using Terraform locals to pre-process your maps before passing them to for_each is a common companion pattern. I use locals constantly to filter, merge, or reshape data before the resource loop touches it.
for_each With Sets
Maps give you key-value access. Sets give you a deduplicated collection when you only need the key, not associated data.
Creating IAM users from a list of names:
variable "iam_users" {
type = set(string)
default = ["alice", "bob", "charlie"]
}
resource "aws_iam_user" "team" {
for_each = var.iam_users
name = each.key
}
resource "aws_iam_user_policy_attachment" "team" {
for_each = var.iam_users
user = aws_iam_user.team[each.key].name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
With sets, each.key and each.value are the same thing — the string element itself. Note that you must pass a set(string), not a list(string). This catches people constantly. If you try to use a list directly with for_each, Terraform will error at plan time and tell you to convert it with toset().
# This works
for_each = toset(var.iam_users)
# This doesn't
for_each = var.iam_users # if var.iam_users is list(string)
A Real-World Pattern: Multiple Security Groups From a Variable Map
Here’s something close to what I use in production for creating security groups per service:
variable "service_security_groups" {
type = map(object({
description = string
ingress_rules = list(object({
from_port = number
to_port = number
protocol = string
cidr_blocks = list(string)
}))
}))
}
locals {
# Flatten security group ingress rules for a separate resource block
sg_ingress_rules = flatten([
for sg_name, sg in var.service_security_groups : [
for idx, rule in sg.ingress_rules : {
sg_name = sg_name
rule_index = idx
from_port = rule.from_port
to_port = rule.to_port
protocol = rule.protocol
cidr_blocks = rule.cidr_blocks
}
]
])
}
resource "aws_security_group" "services" {
for_each = var.service_security_groups
name = each.key
description = each.value.description
vpc_id = var.vpc_id
}
resource "aws_security_group_rule" "ingress" {
for_each = {
for rule in local.sg_ingress_rules :
"${rule.sg_name}-${rule.rule_index}" => rule
}
type = "ingress"
security_group_id = aws_security_group.services[each.value.sg_name].id
from_port = each.value.from_port
to_port = each.value.to_port
protocol = each.value.protocol
cidr_blocks = each.value.cidr_blocks
}
The flatten + for expression in locals creates a stable map key by combining the security group name and rule index. Verbose, yes. But every rule has a unique, stable identifier in state.
for_each With Modules
for_each works just as well with modules as with resources. This is where it really pays off for teams managing multiple environments or similar infrastructure patterns.
variable "environments" {
type = map(object({
instance_type = string
min_capacity = number
max_capacity = number
enable_deletion_protection = bool
}))
default = {
"production" = {
instance_type = "t3.large"
min_capacity = 3
max_capacity = 10
enable_deletion_protection = true
}
"staging" = {
instance_type = "t3.medium"
min_capacity = 1
max_capacity = 3
enable_deletion_protection = false
}
}
}
module "app_cluster" {
source = "./modules/app-cluster"
for_each = var.environments
environment = each.key
instance_type = each.value.instance_type
min_capacity = each.value.min_capacity
max_capacity = each.value.max_capacity
enable_deletion_protection = each.value.enable_deletion_protection
}
Outputs from a for_each module are maps keyed by the same strings:
output "cluster_endpoints" {
value = {
for env, cluster in module.app_cluster :
env => cluster.endpoint
}
}
This gives you cluster_endpoints["production"] and cluster_endpoints["staging"] as output values. Clean and predictable. See Terraform modules for a deeper dive into module patterns.
When count Is Actually Fine
I want to push back on the “always use for_each” advice you’ll find in a lot of guides. count is genuinely the right tool in a few situations.
Simple on/off flags. The most common case: you have a resource you want to conditionally create.
variable "create_bastion" {
type = bool
default = false
}
resource "aws_instance" "bastion" {
count = var.create_bastion ? 1 : 0
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
}
Writing this with for_each would be awkward and unreadable. count = 0 or count = 1 is exactly what this pattern calls for. The resource is either there or it isn’t. Using for_each = var.create_bastion ? { "bastion" = {} } : {} works but nobody should be forced to read that.
Identical resources you’ll never need to remove individually. If you need three identical read replicas and you’ll always have exactly three, count = 3 is fine. The risk of integer indexing biting you goes away when the collection is homogeneous and stable.
The test I use: “Will I ever need to remove one item from the middle of this collection without removing the others?” If yes, use for_each. If no, count is fine.
Terraform count has more detail on the specific patterns where count is genuinely the right call, including how to reference count.index when you need it.
Migrating From count to for_each
You’ve inherited a codebase using count where it shouldn’t. Or you’ve made the mistake yourself. Here’s how to fix it without destroying and recreating real infrastructure.
Before Terraform 1.1, this was painful manual state surgery. Now you use moved blocks.
Start with the existing state using count:
# Before: using count
resource "aws_s3_bucket" "data" {
count = length(var.bucket_names)
bucket = var.bucket_names[count.index]
}
State looks like:
aws_s3_bucket.data[0]→"prod-data"aws_s3_bucket.data[1]→"staging-data"aws_s3_bucket.data[2]→"dev-data"
To migrate to for_each, change the resource block and add moved blocks:
# After: using for_each
resource "aws_s3_bucket" "data" {
for_each = toset(var.bucket_names)
bucket = each.key
}
# Tell Terraform how to map old addresses to new ones
moved {
from = aws_s3_bucket.data[0]
to = aws_s3_bucket.data["prod-data"]
}
moved {
from = aws_s3_bucket.data[1]
to = aws_s3_bucket.data["staging-data"]
}
moved {
from = aws_s3_bucket.data[2]
to = aws_s3_bucket.data["dev-data"]
}
Run terraform plan. You should see no creates or destroys — only moves. If the plan shows destroys, stop and read the output carefully before proceeding.
Once you’ve applied the migration, you can remove the moved blocks from the code. They’ve done their job.
For more complex migrations or when you’re connecting this work to a GitLab pipeline, Run Terraform from GitLab CI covers the workflow for safely applying state migrations through CI.
Common Pitfalls
Lists Are Not Sets
variable "users" {
type = list(string)
default = ["alice", "bob"]
}
# This fails
resource "aws_iam_user" "team" {
for_each = var.users # Error: for_each requires a map or set
name = each.key
}
# This works
resource "aws_iam_user" "team" {
for_each = toset(var.users)
name = each.key
}
toset() deduplicates and converts. If your list has duplicate values, toset() will silently drop them. That’s usually what you want. If it’s not, you have a data problem upstream.
Unknown Values at Plan Time
This one stings. for_each requires that the keys be known at plan time. If your map key comes from a resource attribute that doesn’t exist yet — like an ID that only gets assigned at apply time — Terraform will refuse to plan.
# This fails if the bucket is being created in the same plan
resource "aws_s3_bucket_notification" "example" {
for_each = {
for bucket in aws_s3_bucket.my_buckets :
bucket.id => bucket # bucket.id is unknown at plan time
}
}
The fix is to key by something known at plan time — usually the input variable values rather than the computed resource attributes.
# This works because we're keying by input values, not computed IDs
resource "aws_s3_bucket_notification" "example" {
for_each = aws_s3_bucket.my_buckets # keys are the for_each keys from the bucket resource
bucket = each.value.id
}
Referencing the resource directly as a map (when that resource also used for_each) passes through the same keys, which Terraform already knows.
Mixing for_each and count on the Same Resource
You cannot use both on the same resource block. Pick one. If you need conditional creation combined with iteration, handle the conditionality in the map you pass to for_each:
locals {
active_buckets = {
for name, config in var.buckets :
name => config
if config.enabled
}
}
resource "aws_s3_bucket" "data" {
for_each = local.active_buckets
bucket = each.key
}
Filter in locals, pass the filtered result to for_each. Clean separation.
The Full Pattern: Multiple AWS Resources From a Variable Map
Here’s what the full pattern looks like in a real module — IAM users with group memberships and policies, all driven by a single input map:
variable "team_members" {
type = map(object({
email = string
groups = list(string)
policy_arns = list(string)
}))
}
resource "aws_iam_user" "members" {
for_each = var.team_members
name = each.key
tags = {
Email = each.value.email
}
}
resource "aws_iam_user_login_profile" "members" {
for_each = var.team_members
user = aws_iam_user.members[each.key].name
password_reset_required = true
}
locals {
# Flatten user-group associations into a stable map
user_group_memberships = merge([
for username, config in var.team_members : {
for group in config.groups :
"${username}-${group}" => {
username = username
group = group
}
}
]...)
# Flatten user-policy associations
user_policy_attachments = merge([
for username, config in var.team_members : {
for policy_arn in config.policy_arns :
"${username}-${replace(policy_arn, "/", "-")}" => {
username = username
policy_arn = policy_arn
}
}
]...)
}
resource "aws_iam_user_group_membership" "members" {
for_each = local.user_group_memberships
user = aws_iam_user.members[each.value.username].name
groups = [each.value.group]
}
resource "aws_iam_user_policy_attachment" "members" {
for_each = local.user_policy_attachments
user = aws_iam_user.members[each.value.username].name
policy_arn = each.value.policy_arn
}
The pattern: define the data in a variable map, use for_each on the resource, and use locals + merge with the splat operator (...) to flatten nested structures into stable maps for related resources.
This works reliably in CI pipelines. You can add a user, remove a user, or change a policy — and only the affected resources change. Compare this to a count-based approach where removing a user in the middle of the list would trigger cascading changes.
The OpenTofu Angle
If you’re following the Terraform vs OpenTofu 2026 conversation, the good news is that for_each works identically in both. The syntax, behavior, and state representation are the same. Any pattern in this post works in either tool. The moved block migration approach works in OpenTofu as well — it was introduced before the fork and both projects maintain it.
Decision Summary
Use for_each when:
- Your collection is a map or set of meaningful string keys
- You might need to add or remove individual items later
- Items have associated configuration data (use a map of objects)
- You’re creating resources across environments or named groups
Use count when:
- You need a simple boolean “create this or don’t”
- You need N identical, homogeneous resources that you’ll always scale together
- The resource has no meaningful name to use as a key
Default to for_each. Reach for count only when you have a specific reason.
Related Posts
- Terraform count — When integer indexing is actually the right tool
- Terraform locals — How to pre-process data before it reaches for_each
- Terraform modules — Using for_each with reusable module patterns
- Terraform Import 2026 — Importing existing resources into for_each-managed state
Comments