Skip to main content
Terraform IaC Platform Engineering DevOps Cloud Architecture

I stopped using count in Terraform modules. Here's what I use instead.

YN
Yaroslav Naumenko
|

I stopped using count in Terraform modules. Here’s what I use instead.

I’ll start with the incident, because that’s what actually convinced me.

Someone on the team opened a PR to remove a single S3 bucket from a list of five. Innocent change. One line deleted from a locals.tf. The plan came back with five resources to destroy and four to create. On a good day you catch that in review. On a bad day — and we’ve all had that bad day — you don’t, and now you’re restoring state from backups while your Slack channel turns into a crime scene.

That’s the count footgun. And I stopped reaching for it in Terraform modules a long time ago.

Why count breaks under real-world change

count indexes resources by position. aws_s3_bucket.this[0], aws_s3_bucket.this[1], aws_s3_bucket.this[2], and so on. Terraform tracks them in state by that numeric index.

The moment you remove an item that isn’t the last one in the list, every item after it shifts down. Terraform doesn’t see “one resource deleted” — it sees “index 2 is now a different resource, index 3 is now a different resource, index 4 is now a different resource.”

So when I deleted the third bucket out of five, the plan said:

  • bucket[2] will be destroyed and recreated (it’s now what used to be bucket[3])
  • bucket[3] will be destroyed and recreated (it’s now what used to be bucket[4])
  • bucket[4] will be destroyed (it used to exist, now the list is shorter)

That’s three destroy operations for what should have been one. And the real problem isn’t S3 buckets — those are usually fine with versioning and replication. The problem is when the same pattern is applied to databases, load balancers with TLS certs attached, or anything with a dependency tree that takes hours to rebuild.

What I use instead: for_each with stable string keys

For every module that manages a collection of things, I use for_each with a map keyed by a meaningful, stable identifier.

locals {
  buckets = {
    analytics = { versioning = true,  lifecycle_days = 90 }
    billing   = { versioning = true,  lifecycle_days = 365 }
    logs      = { versioning = false, lifecycle_days = 30 }
  }
}

resource "aws_s3_bucket" "this" {
  for_each = local.buckets
  bucket   = "example-${each.key}"
}

Now Terraform tracks state by the key, not by position:

  • aws_s3_bucket.this["analytics"]
  • aws_s3_bucket.this["billing"]
  • aws_s3_bucket.this["logs"]

Remove billing from the map, and the plan shows exactly one destroy: aws_s3_bucket.this["billing"]. Nothing else moves. The blast radius matches the intent.

A few things to keep in mind:

  • The key has to be known at plan time. If you try to derive it from a resource output that Terraform hasn’t created yet, you’ll get the classic “The for_each map must not contain values derived from resource attributes” error. Lift the key into a locals block or a variable.
  • The key has to be stable. Renaming the key is a destroy+create, just like changing a count index. Pick names you won’t want to change — things like service names, environments, or purpose, not anything tied to a version or a date.
  • Use sets for simple lists. If you don’t have per-item configuration, for_each = toset([...]) still gives you stable keys without forcing a map structure.

The conditional resource pattern (zero or one)

The one place count still shows up in a lot of codebases is the “create this resource if a flag is true” pattern:

resource "aws_cloudwatch_log_group" "this" {
  count = var.enable_logging ? 1 : 0
  name  = "/aws/app/${var.name}"
}

This technically works. Toggling the flag only creates or destroys the single resource, which is what you want. But I’ve moved to for_each with an empty set even here, for one reason: consistency.

resource "aws_cloudwatch_log_group" "this" {
  for_each = var.enable_logging ? toset(["enabled"]) : toset([])
  name     = "/aws/app/${var.name}"
}

Every reference to the resource becomes aws_cloudwatch_log_group.this["enabled"] instead of aws_cloudwatch_log_group.this[0]. Every module in the codebase uses the same iteration primitive. Reviewers stop asking “wait, is this one a count or a for_each?” and just read the logic.

It’s a small cost — one extra line of ternary — for a codebase that reads the same way everywhere.

The rough heuristic

When I’m writing a new module or reviewing a PR, the question I ask is:

If the thing you’re iterating over has a natural name, use for_each with a map or set. If it doesn’t have a natural name, you’re probably modeling it wrong in the first place.

It’s a heuristic, not a definitive test. But I’d be lying if I said I’ve hit a case in the last few years where this rule steered me wrong.

Buckets have names. Subnets have names (or availability zones, which work just as well as keys). IAM roles have names. DNS records have names. Load balancer listeners have ports. Security group rules have a purpose. Anything you’d actually iterate over in production has a natural identifier — and if it doesn’t, that’s usually a smell that the data model needs more thought before you reach for any iteration primitive at all.

Migrating from count to for_each without a destroy storm

One last thing, because this is where most people get stuck: you already have a bunch of modules using count, and you want to move them to for_each. The naive terraform apply will show a full destroy+create because the resource addresses change.

The fix is terraform state mv (or a moved block, which I prefer now because it lives in code and survives terraform init on a fresh workstation):

moved {
  from = aws_s3_bucket.this[0]
  to   = aws_s3_bucket.this["analytics"]
}

moved {
  from = aws_s3_bucket.this[1]
  to   = aws_s3_bucket.this["billing"]
}

Run terraform plan and confirm it shows zero changes, just state moves. Then apply. Do one resource type at a time, and do it during a quiet window. It took me embarrassingly long to start using moved blocks instead of state manipulation commands, but they’re genuinely a better tool — they’re declarative, they’re versioned, and they don’t require anyone with prod credentials to run CLI commands manually.

The one-line takeaway

count indexes by position. Position is not identity. The moment your list shrinks in the middle, Terraform’s view of the world no longer matches yours, and that gap is where prod outages live.

for_each with stable string keys makes identity explicit. Which is what you wanted all along.

YN

Yaroslav Naumenko

Cloud Infrastructure Architect specializing in PCI/HIPAA/FedRAMP compliant solutions at scale. Over a decade building on AWS & GCP.

Need Help With Your Cloud Infrastructure?

Book a free 15-minute call and let's discuss your needs.