Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Terraform By James Joyner IV · · 10 min read

for_each Set vs Map Keys in Terraform: Stop the Churn

The set-vs-map choice behind a Terraform for_each decides your instance addresses — and whether the next edit is a no-op or a destroy. Here's how to pick keys that survive change.

  • #terraform
  • #ai
  • #for_each
  • #hcl
  • #state

Two for_each blocks can look almost identical and behave completely differently when the underlying collection changes. One produces a clean no-op when you add an element; the other quietly destroys and recreates half your resources. The difference isn’t the resource — it’s whether you fed for_each a set of strings or a map keyed by something stable.

This is one of those Terraform details that doesn’t matter at all until it matters enormously, usually on a stateful resource you can’t afford to recreate. Let’s make the choice deliberate.

Instance keys are a contract with state

When you use for_each, Terraform stores each instance under its key. That key becomes part of the resource address — aws_iam_user.this["alice"] — and the address is how Terraform tracks the instance in state across runs. Change the key and Terraform doesn’t see “the same instance with a new label”; it sees one instance disappearing and a new one appearing. That means a destroy and a create.

So the real question behind every for_each is: what is the stable identity of each element, and is my key derived from it?

The set-of-strings case

A set(string) is the right input when the string value is the identity:

variable "team_emails" {
  type    = set(string)
  default = ["alice@example.com", "bob@example.com"]
}

resource "aws_iam_user" "team" {
  for_each = var.team_emails
  name     = each.value
}

Here the key and the value are the same string. Add carol@example.com and you get a clean create with no churn on alice or bob. The trap is using toset over values that can change:

# Risky: key is derived from a mutable attribute
resource "aws_iam_user" "team" {
  for_each = { for u in var.users : u.email => u }
  name     = each.value.name
}

If someone corrects a typo in an email, that user’s key changes, and Terraform destroys and recreates the IAM user. For a user that might be acceptable; for a database it is an outage.

The map-of-objects case

Reach for a map(object) keyed by a deliberately chosen, stable id whenever your elements carry attributes or whenever the identity must survive value edits:

variable "users" {
  type = map(object({
    name  = string
    email = string
    admin = bool
  }))
  default = {
    alice = { name = "Alice", email = "alice@example.com", admin = true }
    bob   = { name = "Bob",   email = "bob@example.com",   admin = false }
  }
}

resource "aws_iam_user" "team" {
  for_each = var.users
  name     = each.value.name
}

Now the key is alice — a human-chosen handle, not a mutable email. Fix Alice’s email and the plan is a single in-place update, not a recreate. The key is doing its real job: providing a stable identity that’s independent of the values it labels.

Refactoring from a set to a map safely

If you already have a toset-based resource in production and want to move to stable map keys, you can’t just swap the expression — the keys will change and Terraform will plan to recreate everything. Use moved blocks to tell Terraform that the old addresses map to the new ones:

moved {
  from = aws_iam_user.team["alice@example.com"]
  to   = aws_iam_user.team["alice"]
}

Add one moved block per existing instance, run a plan, and confirm it shows moves with no create or destroy of any stateful resource. If you see a destroy, a key still doesn’t line up — fix the mapping, not the state. Our Terraform Moved & Import Blocks prompt walks through generating these declaratively.

Let AI propose keys, then verify against a plan

This is a great task to hand to an AI assistant, because it’s tedious and pattern-based — but only if you verify the result against a real plan rather than trusting it. A prompt like:

Here’s a for_each resource keyed off toset([for u in var.users : u.email]). The emails can change. Propose a map(object) design with stable keys, the rewritten expression, and the moved blocks needed to preserve the existing instances. Then tell me exactly what terraform plan should show — and flag any case that would recreate a stateful resource.

A capable model will return:

Switch to a map keyed by a stable username. Add moved blocks from each ["<email>"] address to ["<username>"]. The plan should show N moves and zero create/destroy. Caveat: if any user lacks a unique stable username, the move is ambiguous — supply one before applying.

That caveat is the whole point. The AI drafts the refactor; you run terraform plan and confirm it’s a pure set of moves before anything touches state. Our for_each set vs map keys prompt is built around exactly this verify-first loop.

Guard against future churn

Once the keys are right, keep them right. Validation blocks catch the regressions before they reach state:

variable "users" {
  type = map(object({
    name  = string
    email = string
  }))

  validation {
    condition     = alltrue([for k, _ in var.users : can(regex("^[a-z0-9_-]+$", k))])
    error_message = "User keys must be stable, lowercase handles — not emails or computed values."
  }
}

This rejects the tempting-but-dangerous habit of keying off an email or an ARN before it ever causes a recreate.

The rule of thumb

Use a set(string) only when the element value is the stable identity and will not change. Use a map(object) keyed by a caller-chosen handle whenever the elements have attributes or whenever the identity must outlive value edits. Never key off something Terraform computes or something a human might correct.

Get that decision right at design time and for_each stays boring — clean adds, clean removes, in-place updates. Get it wrong and you’ll learn about it the hard way, on the one resource you couldn’t afford to recreate. For the broader trade-off between for_each and other patterns, browse the rest of our Terraform guides and prompts.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.