Meta Arguments
Five arguments work on every resource block regardless of type, because they're handled by Terraform
rather than by a provider: count, for_each, depends_on, lifecycle and provider. They look
like syntax. They are graph operations — and the most expensive mistake in Terraform lives in the
difference between two of them.
Prerequisites: The Dependency Graph
What & Why
A meta-argument is an argument you can use in any resource block — and mostly in module and
data blocks too — which Terraform interprets itself rather than passing to the provider. They don't
appear in provider documentation because they aren't provider features; they change how many graph
nodes a block produces, what those nodes are called, what edges they have, and what actions are
permitted on them.
The bad practice it replaces
Copy-paste. Before count and for_each, three near-identical buckets meant three near-identical
blocks, and the fourth was added by someone who forgot to change one field. The features exist to
express "one description, many instances".
But the specific bad practice this page attacks is more current and much more damaging: using
count to iterate over a list of named things. It's the first tool people find, it works, and it
quietly encodes a rule that the position of an item in your list is that resource's permanent
identity. Delete the first of three buckets and Terraform will propose destroying and recreating the
other two, because they've moved from index 1 and 2 to index 0 and 1. This is the single most common
self-inflicted production disaster in Terraform, and it is entirely avoidable by knowing one thing
about addressing.
Where it sits
Meta-arguments come after the dependency graph on purpose. Taught as syntax, for_each is a loop and
you never think about addresses again. Taught after the graph, the important property states itself:
each instance is a separate graph node with its own address, and count derives that address from
position while for_each derives it from a key you chose. Everything else on this page follows.
Three things they're confused with
count and for_each are not loops. Nothing iterates. Each expands one configuration block into
N independent graph nodes which are then planned and applied concurrently, in arbitrary order. There
is no first iteration and no variable that increments.
lifecycle is not error handling. It doesn't catch failures or retry. It constrains what actions
Terraform is allowed to plan, and in the case of create_before_destroy it rewrites graph edges.
depends_on is not how you express dependencies. Covered in
The Dependency Graph: if a resource uses another's value, the edge already
exists. depends_on is for the residue.
When NOT to use them
countfor anything with an identity. If the instances are distinguishable things — environments, regions, named buckets, teams — usefor_each. Reservecountfor genuinely interchangeable instances and for the conditional-creation idiom below.count = 0as a feature flag on a large block. It works, and a configuration where half the resources are conditionally absent is very hard to reason about. Past two or three flags, separate configurations or modules are clearer.ignore_changesto silence a noisy plan. It's a deliberate statement that another system owns that attribute. Using it because you don't know why the plan keeps changing converts a visible problem into an invisible one.prevent_destroyas your only protection. It's a good tripwire and a poor control: it blocksterraform destroyfor the whole configuration, it's frequently removed in a hurry by whoever hits it, and it does nothing about replacement caused by an immutable attribute — that still destroys the resource. ⚠️ verify: confirm whetherprevent_destroyblocks replacement as well as destroy.provideron individual resources, scattered. Aliased providers are necessary for multi-region work, and per-resourceproviderarguments spread through a configuration are hard to audit. Prefer passing providers into modules.
Core Concepts
Meta-argument — an argument Terraform handles, not the provider. Valid on any resource block:
count, for_each, depends_on, lifecycle, provider. Modules additionally take source and
version, and a providers map.
Resource address — the unique name of one instance. aws_s3_bucket.demo with no
meta-arguments; aws_s3_bucket.demo[0] with count; aws_s3_bucket.demo["logs"] with for_each.
This string is the key in state, and changing it means Terraform thinks a different resource exists.
Instance key — the part in brackets. An integer index under count, a string key under
for_each. The key is part of the identity. Change the key and Terraform sees one resource
destroyed and another created.
count — make N copies, addressed by position. Takes a whole number. Creates instances [0]
through [N-1]. The count value must be known at plan time.
for_each — make one instance per element, addressed by key. Takes a map, or a set of strings.
Creates one instance per key. Inside the block, each.key and each.value refer to the current
element.
Index shifting — the disaster. Because count addresses are positional, removing or inserting
an element anywhere but the end changes the address of every subsequent instance, and Terraform reads
that as destroy-and-recreate.
depends_on — an explicit edge. A static list of resource or module references, no attributes
and no expressions. See The Dependency Graph.
lifecycle — constraints on permitted actions. A block containing create_before_destroy,
prevent_destroy, ignore_changes, replace_triggered_by, and the precondition/postcondition
blocks that belong to Testing & Validation. Its arguments
must be literals — they cannot reference variables, because they're needed before values are
resolved.
create_before_destroy — create the replacement first. Inverts the local edges during
replacement so the new instance exists before the old is removed. Renders as +/- in a plan.
prevent_destroy — refuse to plan a destroy. Terraform errors instead of producing a plan that
would remove this resource.
ignore_changes — stop diffing these attributes. A list of attribute names, or all. Terraform
accepts whatever is in reality for those attributes and never proposes changing them.
replace_triggered_by — replace this when that changes. A list of references; when any changes,
this resource is replaced. Terraform 1.2+. ⚠️ verify version.
provider — which configured provider to use. Selects an aliased provider configuration, for
multi-region and multi-account work.
Provider alias — a second configuration of the same provider. Declared with alias on a
provider block and referenced as aws.alias_name.
How It Works
count and for_each produce graph nodes, not iterations
One block, three nodes. The nodes have no relationship to each other — no edges — so they are planned and applied concurrently in arbitrary order. What differs between the two meta-arguments is only what the nodes are called.

variable "bucket_names" {
type = list(string)
default = ["logs", "media", "backups"]
}
resource "aws_s3_bucket" "by_count" {
count = length(var.bucket_names)
bucket = "tf-meta-demo-${var.bucket_names[count.index]}"
}
resource "aws_s3_bucket" "by_for_each" {
for_each = toset(var.bucket_names)
bucket = "tf-meta-demo-${each.key}"
}
The addresses in state:
count |
for_each |
|---|---|
aws_s3_bucket.by_count[0] |
aws_s3_bucket.by_for_each["logs"] |
aws_s3_bucket.by_count[1] |
aws_s3_bucket.by_for_each["media"] |
aws_s3_bucket.by_count[2] |
aws_s3_bucket.by_for_each["backups"] |
Identical in azurerm and google — this is Terraform's addressing, not a provider's.
Why index shifting destroys things
Remove "logs" from the list. Under for_each, the key "logs" disappears and the other two keys are
untouched:

# UNVERIFIED — confirm against a real run
# aws_s3_bucket.by_for_each["logs"] will be destroyed
Plan: 0 to add, 0 to change, 1 to destroy.
Correct, and obvious in hindsight. Under count, the list is now ["media", "backups"], so:
# UNVERIFIED — confirm against a real run
# aws_s3_bucket.by_count[0] must be replaced
-/+ bucket = "tf-meta-demo-logs" -> "tf-meta-demo-media" # forces replacement
# aws_s3_bucket.by_count[1] must be replaced
-/+ bucket = "tf-meta-demo-media" -> "tf-meta-demo-backups" # forces replacement
# aws_s3_bucket.by_count[2] will be destroyed
Plan: 2 to add, 0 to change, 3 to destroy.
You removed one item and Terraform proposes to destroy three resources and create two. Nothing has
gone wrong; Terraform is doing exactly what it was told. [0] was the logs bucket and must now be the
media bucket, and since the name is immutable that means replacement. Substitute "database" for
"bucket" and this is an outage with data loss, produced by a one-line change that looks trivial in
review.
The rule: count says position is identity. for_each says the key is identity. Named things
have identities, so they need for_each.
What for_each demands in return
Keys must be known at plan time, because they are addresses and Terraform cannot plan against
resources whose names it doesn't know yet. So a for_each derived from an attribute of a resource
that doesn't exist yet fails:
# UNVERIFIED — confirm against a real run
Error: Invalid for_each argument
The "for_each" value depends on resource attributes that cannot be determined
until apply, so Terraform cannot predict how many instances will be created.
The fix is to derive keys from variables, locals or data sources — things resolvable at plan time — even when the values come from resources. Keys from configuration, values from wherever.
for_each takes a map or a set of strings, never a list. A list of strings needs toset(),
and that conversion has a consequence worth knowing: sets are unordered and de-duplicated, so with
toset(), each.key and each.value are the same string. When you need richer data per instance,
use a map, and prefer a map keyed by a stable identifier over one keyed by anything that might be
edited.
The one good use of count
Conditional creation of a single resource:
resource "aws_s3_bucket" "audit_logs" {
count = var.enable_audit_logging ? 1 : 0
bucket = "${var.name_prefix}-audit"
}
# Referenced with an index, and safely only when it exists:
output "audit_bucket" {
value = one(aws_s3_bucket.audit_logs[*].id)
}
The awkwardness — index [0], splat expressions, one() — is the price. Some people prefer
for_each over a conditional set for this too, which avoids indices entirely; both are defensible.
lifecycle, argument by argument
create_before_destroy = true inverts local edges so the replacement is created before the
original is destroyed. Essential for anything that must not have a gap in service. Two consequences:
the new instance must be able to coexist with the old, which for named resources means it needs a
different name — hence name_prefix-style arguments where providers offer them — and the inverted edge
can close a loop, producing a cycle error somewhere apparently unrelated. It also propagates: dependents
generally need it too, or the ordering doesn't achieve what you wanted.

prevent_destroy = true makes Terraform refuse to produce a plan that destroys the resource. It's
a tripwire, and its limits matter: it blocks terraform destroy for the entire configuration, so
someone tearing down a dev environment hits it and removes it in irritation; and it can't reference a
variable, so "prevent destroy in production only" isn't expressible. That last constraint pushes people
toward per-environment configurations, which is the right answer anyway.
ignore_changes = [tags["LastModified"]] tells Terraform to accept reality for those attributes.
Legitimate when another system genuinely owns an attribute: an autoscaler managing capacity, a
deployment pipeline updating an image tag, a cloud service adding its own tags. ignore_changes = all
exists and is almost always wrong — it means "manage this resource's existence but nothing about it",
and if that's true, question whether it should be in Terraform at all.
replace_triggered_by = [aws_s3_bucket.config] replaces this resource when the referenced one
changes. Useful for the case where a resource caches something at creation time and has no way to
refresh it.
resource "aws_s3_bucket" "data" {
bucket = "${var.name_prefix}-data"
lifecycle {
prevent_destroy = true # literal only — cannot be var.protect
ignore_changes = [
tags["LastScanned"], # a compliance scanner writes this
]
}
}
provider and aliases — where the clouds genuinely differ
The mechanism is identical: declare a second provider configuration with an alias, then select it
per resource. What differs is what you're changing — and that reflects each cloud's scoping model.
```hcl
provider "aws" {
region = "eu-west-1" # default
}
provider "aws" {
alias = "us"
region = "us-east-1"
}
resource "aws_s3_bucket" "eu" {
bucket = "${var.name_prefix}-eu"
}
resource "aws_s3_bucket" "us" {
provider = aws.us # ← the meta-argument
bucket = "${var.name_prefix}-us"
}
```
Region is the axis, and it lives on the provider — so multi-region always means
multiple provider configurations. Cross-account works the same way, with
`assume_role` on the aliased provider.
```hcl
provider "azurerm" {
features {} # default subscription
}
provider "azurerm" {
alias = "secondary"
subscription_id = var.secondary_subscription_id
features {}
}
resource "azurerm_storage_account" "primary" {
name = "${var.name_prefix}pri"
resource_group_name = azurerm_resource_group.primary.name
location = "westeurope"
account_tier = "Standard"
account_replication_type = "LRS"
}
resource "azurerm_storage_account" "secondary" {
provider = azurerm.secondary
name = "${var.name_prefix}sec"
resource_group_name = azurerm_resource_group.secondary.name
location = "northeurope"
account_tier = "Standard"
account_replication_type = "LRS"
}
```
Location is a *resource* attribute, so multi-region needs no alias at all —
aliases are for crossing **subscriptions**. This is the biggest practical
difference between the three: the same intent needs an alias on AWS and doesn't
on Azure.
```hcl
provider "google" {
project = var.project_id # default
region = "europe-west1"
}
provider "google" {
alias = "other_project"
project = var.other_project_id
region = "europe-west1"
}
resource "google_storage_bucket" "primary" {
name = "${var.name_prefix}-primary"
location = "EUROPE-WEST1"
}
resource "google_storage_bucket" "other" {
provider = google.other_project
name = "${var.name_prefix}-other"
location = "EUROPE-WEST1"
}
```
Location is on the resource, like Azure — so aliases are for crossing
**projects**. Region alone rarely needs one.
The pattern: the alias axis is whatever the cloud treats as a boundary you can't cross from one configuration — region and account on AWS, subscription on Azure, project on GCP. Because AWS puts region on the provider, multi-region AWS configurations need aliases where the equivalent Azure and GCP configurations don't. The canonical scoping table is in Providers & the Registry.
Getting Started
The point of this demonstration is to see the index-shift disaster in a plan, safely, on empty buckets.
Shown in AWS; identical in azurerm and google.
# main.tf
terraform {
required_version = ">= 1.5"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" {
region = "eu-west-1"
}
variable "names" {
type = list(string)
default = ["logs", "media", "backups"]
}
resource "aws_s3_bucket" "by_count" {
count = length(var.names)
bucket = "tf-meta-count-${var.names[count.index]}-0725"
}
resource "aws_s3_bucket" "by_for_each" {
for_each = toset(var.names)
bucket = "tf-meta-each-${each.key}-0725"
}
terraform init && terraform apply
terraform state list
# UNVERIFIED — confirm against a real run
aws_s3_bucket.by_count[0]
aws_s3_bucket.by_count[1]
aws_s3_bucket.by_count[2]
aws_s3_bucket.by_for_each["backups"]
aws_s3_bucket.by_for_each["logs"]
aws_s3_bucket.by_for_each["media"]
The addresses are the lesson. Integers on one, strings on the other, and note the for_each
addresses are sorted by key while the count ones are positional.
Now remove the first element — the worst case:
terraform plan -var 'names=["media","backups"]'
# UNVERIFIED — confirm against a real run
# aws_s3_bucket.by_count[0] must be replaced
-/+ resource "aws_s3_bucket" "by_count" {
~ bucket = "tf-meta-count-logs-0725" -> "tf-meta-count-media-0725" # forces replacement
}
# aws_s3_bucket.by_count[1] must be replaced
-/+ resource "aws_s3_bucket" "by_count" {
~ bucket = "tf-meta-count-media-0725" -> "tf-meta-count-backups-0725" # forces replacement
}
# aws_s3_bucket.by_count[2] will be destroyed
# aws_s3_bucket.by_for_each["logs"] will be destroyed
Plan: 2 to add, 0 to change, 4 to destroy.
Read the two halves side by side. for_each: one destroy, exactly what you asked for. count: three
destroys and two creates, because every index after the removed element shifted. Do not apply this.
Then prove for_each handles insertion too — add a key in the middle:
terraform plan -var 'names=["logs","archive","media","backups"]'
The for_each resources show one create and nothing else. The count resources churn again.
Finally, see what lifecycle does to a plan. Add to the for_each block:
lifecycle {
prevent_destroy = true
}
terraform plan -var 'names=["media","backups"]'
# UNVERIFIED — confirm against a real run
Error: Instance cannot be destroyed
on main.tf line 24:
24: resource "aws_s3_bucket" "by_for_each" {
Resource aws_s3_bucket.by_for_each["logs"] has lifecycle.prevent_destroy set,
but the plan calls for this resource to be destroyed.
No plan at all — the tripwire fired. Remove the lifecycle block, then:
terraform destroy
In Practice
Default to for_each. Justify count. The production rule that prevents the whole class of
problem. count is right for conditional creation of one resource and for genuinely interchangeable
instances; everything else gets for_each with a map keyed by something stable.
variable "buckets" {
description = "Object storage buckets to create, keyed by logical name."
type = map(object({
versioning_enabled = bool
retention_days = number
}))
validation {
condition = alltrue([for k in keys(var.buckets) : can(regex("^[a-z0-9-]+$", k))])
error_message = "Bucket keys must be lowercase alphanumeric with hyphens — they become resource addresses."
}
}
locals {
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
Project = var.project
}
}
resource "aws_s3_bucket" "this" {
for_each = var.buckets
bucket = "${var.name_prefix}-${each.key}"
tags = local.common_tags
}
That validation block is doing real work: it enforces that keys are well-formed because they become
resource addresses, which is a constraint no provider will check for you.
Choose keys you will never want to change. This is the practical corollary and it's easy to get
wrong. A map keyed by a logical name ("logs", "media") is stable. A map keyed by something that
reads like data — a region that might be reorganised, a team name that might be renamed, an index — is
a future destroy. If a key must change, that's a moved block, not an edit; see
Import & Refactoring.
Migrating count to for_each is a refactor with a defined tool. Changing the meta-argument alone
changes every address, so the plan proposes destroying and recreating everything. moved blocks
declare the address changes so Terraform updates state instead:
moved {
from = aws_s3_bucket.by_count[0]
to = aws_s3_bucket.by_for_each["logs"]
}
One per instance, and the plan should end up reporting no infrastructure changes at all. That "no changes" result is the proof the migration is correct. Detail in Import & Refactoring.
Version-pin lifecycle decisions in the code review, not in a wiki. Every lifecycle block is a
statement about ownership or risk, and it should carry a comment saying why:
lifecycle {
# The cluster autoscaler owns desired_count; Terraform sets the initial value only.
ignore_changes = [desired_count]
}
Without the comment, the next engineer cannot tell a deliberate concession from a workaround somebody applied to stop a noisy plan, and will either remove it or copy it somewhere it doesn't belong.
What a reviewer should look for in a diff touching this topic:
- Any
countover a list of named things. This is the headline item. Ask forfor_each. - A change to a
for_eachkey, or to the ordering or contents of acountlist. Look at the plan: is anything being destroyed that shouldn't be? Any change to keys should be accompanied bymovedblocks. - A new
ignore_changes. Which system owns that attribute, and is that written down? No comment, no approval. ignore_changes = all. Nearly always the wrong answer; ask what's actually being managed.- A new
prevent_destroy. Fine, but note that it blocksdestroyfor the whole configuration — does that break the environment teardown job? - A new
create_before_destroy. Can the two instances genuinely coexist? Is there a name collision? Do dependents need it too? - A per-resource
providerargument. Correct target region, subscription or project? These are the mistakes that create resources in the wrong account and are noticed a month later. depends_on. As ever: which reference is missing?
Blast radius and rollback. This page contains the highest-blast-radius one-line change in
Terraform: editing a list that a count iterates over. It shows up in a diff as a trivial edit and in
the plan as mass replacement — which is why the plan, not the diff, is the review artefact. Rollback is
worse than usual here, because reverting the list restores the addresses but not the data in the
resources that were destroyed.
Ecosystem
moved blocks (Terraform 1.1+). The supported way to change a resource's address — including
count to for_each migrations — without destroying anything. The glue: add blocks, plan, confirm no
infrastructure changes, apply, then delete the blocks in a later commit. ⚠️ verify version.
Import & Refactoring.
terraform state mv. The imperative predecessor, still needed occasionally when moved can't
express the change. Operates directly on state, so it deserves the caution that implies.
State & Backends.
tflint. Catches some meta-argument mistakes — invalid for_each types, deprecated patterns — that
terraform validate passes. It won't catch the count-over-named-things problem, which is a design
error rather than a syntax one. Testing & Validation.
for expressions and toset/tomap. The tools that shape data into what for_each accepts.
Building the right map is usually where the real work is, and it's
Expressions & Functions.
Module for_each. Modules take count and for_each too, with the same addressing consequences —
module.storage["logs"]. Often the better factoring than a for_each on every resource inside.
Modules.
Provider-specific note. Meta-arguments are Terraform's and behave identically everywhere. The one real divergence is which axis needs a provider alias — region and account on AWS, subscription on Azure, project on GCP — per the tab set above.
Production
Security
Two exposures. The provider meta-argument decides which account, subscription or project a resource
lands in, and a wrong alias creates real infrastructure in the wrong place, often with different
security controls, and it's typically found weeks later in a bill. Worth an explicit review check.
And ignore_changes on a security-relevant attribute — a policy document, a public-access setting, an
ACL — means Terraform will no longer correct drift there, so a manual widening becomes permanent and
invisible. Never ignore changes on security attributes without an explicit owner named in a comment.
Blast radius
The largest in the article, per line of code. count index shifting can destroy an unbounded number of
resources from a one-line edit, and it passes review because the diff looks trivial. The controls, in
order of effectiveness: use for_each so the problem can't arise; require the plan in the pull
request and read its destroy count; prevent_destroy on data-bearing resources as a tripwire; and
policy checks over plan JSON that fail on unexpected deletes
(Governance & Policy as Code).
Scale
Instance count is where state size comes from. A for_each over two hundred keys is two hundred graph
nodes, two hundred state entries and two hundred refresh API calls on every plan — so the meta-argument
that makes configuration concise is also the one that makes plans slow. Two consequences worth knowing:
very large for_each expansions are a common cause of multi-minute plans, and for_each keys must be
computed at plan time, so a large map built from data sources adds plan-time work before any refresh
happens. Scale & Performance.
Team workflow
The rule to establish and write down: for_each by default, count only for conditionals, and any
change to keys comes with moved blocks. It's the kind of convention that has to be in a review
checklist rather than in someone's head, because the failure is silent in the diff and loud in the plan
— and only if someone reads the plan. Also worth agreeing: every lifecycle block carries a comment
naming the system that owns the attribute or the risk being accepted.
Reliability
create_before_destroy is the meta-argument that improves reliability, by removing the gap during
replacement — and it's the one most likely to surprise you, through name collisions between the old and
new instance, or a cycle produced by its inverted edges. Test it in a non-production environment before
relying on it. prevent_destroy improves reliability only as a tripwire; it is not a backup, and it
does not protect against the destroy half of a replacement. ⚠️ verify that last claim.
Interview Questions
Conceptual
What's the difference between `count` and `for_each`?
Both expand one configuration block into several independent graph nodes. The difference is addressing:
count produces positional addresses — aws_s3_bucket.this[0] — while for_each produces keyed
addresses — aws_s3_bucket.this["logs"]. Since the address is the identity in state, count encodes
"position is identity" and for_each encodes "the key is identity".
That makes for_each correct for anything with a name or identity, and count correct only for
genuinely interchangeable instances and for the conditional count = var.enabled ? 1 : 0 idiom.
Why is `count` over a list of names dangerous?
Because removing or inserting an element anywhere but the end shifts every subsequent index, and an index is an address. Delete the first of three buckets and index 0 must now be what was index 1 — which, since names are immutable, means replacement. So a one-item deletion proposes destroying three resources and creating two.
The reason it's genuinely dangerous rather than merely surprising is that the diff looks trivial — one line removed from a list — and the damage is only visible in the plan. Substitute a database for a bucket and it's an outage with data loss.
Are `count` and `for_each` loops?
No, and the distinction has practical consequences. Nothing iterates: each expands a block into N
independent graph nodes with no edges between them, so they're created concurrently in arbitrary order.
There's no first iteration, no accumulator, no way to make instance 2 depend on instance 1. count.index
and each.key identify a node; they aren't loop variables. If you need genuine sequencing between
instances, the graph has to express it, which usually means they shouldn't be instances of one block.
What are the five meta-arguments and what makes them different from normal arguments?
count, for_each, depends_on, lifecycle and provider. Terraform interprets them itself rather
than passing them to the provider, which is why they work on every resource type and why they don't
appear in provider documentation. Modules also take source, version and a providers map. What they
change is graph structure: how many nodes a block produces, what those nodes are called, what edges they
have, and which actions Terraform is permitted to plan.
When is `ignore_changes` appropriate?
When another system legitimately owns an attribute and Terraform should set the initial value but not enforce it — an autoscaler managing capacity, a deployment pipeline updating an image tag, a cloud service adding its own tags. It's a deliberate concession of ownership, and it deserves a comment naming the owner.
It's inappropriate as a way to silence a plan you don't understand, because that converts a visible
recurring diff into invisible unmanaged configuration. ignore_changes = all is almost always wrong: if
nothing about the resource is managed, question whether it belongs in Terraform. And never on
security-relevant attributes, where it makes a manual widening permanent and silent.
Technical depth
Why must `for_each` keys be known at plan time?
Because the keys are resource addresses, and Terraform must know every address it's planning for
before it can produce a plan — it can't tell you what it will create if it doesn't know how many things
there are or what they're called. So a for_each whose keys derive from an attribute of a
not-yet-created resource fails with an "Invalid for_each argument" error rather than deferring.
The workaround is to split keys from values: derive keys from variables, locals or data sources — resolvable at plan time — while the values inside the block can come from anywhere. Failing that, split into two configurations applied in sequence.
`for_each` accepts a map or a set of strings, not a list. Why does that matter?
Because lists are ordered and permit duplicates, and neither property is meaningful for addresses —
order would reintroduce exactly the positional-identity problem for_each exists to avoid. So a list
must be converted with toset(), which de-duplicates and discards order.
A practical consequence of toset(): each.key and each.value are the same string, so there's no
per-instance data. When you need that, use a map, and key it by something stable rather than by
anything a future edit might change.
How do you migrate from `count` to `for_each` without destroying anything?
moved blocks — one per instance, mapping each old positional address to its new keyed address. Add
them, plan, and confirm the plan reports no infrastructure changes: that result is the proof the
mapping is right, since any mistake shows as a destroy. Apply, then remove the blocks in a later commit.
Before moved blocks existed, the equivalent was a sequence of terraform state mv commands, which
does the same thing imperatively against state and has no plan-level dry run. moved is preferable
because it's declarative, reviewable in a diff, and safe to run repeatedly.
What does `create_before_destroy` actually do, and what breaks with it?
It inverts the local dependency edges during replacement, so the new instance is created before the old
is destroyed — rendered +/- rather than -/+ — which removes the service gap.
Three things break. Name collisions: if the resource has a unique name, two can't coexist, which is why
providers offer name_prefix-style arguments. Cycles: the inverted edge can close a loop with an
existing reference, and the error can appear to come from somewhere unrelated. And propagation:
dependent resources usually need it too, or the ordering doesn't achieve what you intended — which is
easy to miss because the setting is per-resource.
What are the limits of `prevent_destroy`?
It refuses to produce a plan that destroys the resource, which makes it a good tripwire. Its limits:
it blocks terraform destroy for the whole configuration, so legitimate teardown of a dev environment
fails and whoever hits it tends to remove it in a hurry; its value must be a literal, so it can't be
enabled per environment via a variable — pushing you toward separate configurations, which is the
better answer anyway; and it protects the resource, not the data, so it's no substitute for backups.
⚠️ verify whether it also blocks the destroy half of a replacement, which is the case that matters
most.
How does this differ across AWS, Azure and GCP?
count, for_each, depends_on and lifecycle behave identically — they're Terraform's, not the
providers'. The provider meta-argument is where real divergence lives, and it follows each cloud's
scoping model.
AWS puts region on the provider, so any multi-region configuration needs an aliased provider and
a provider argument on the resources. Cross-account is the same mechanism with assume_role.
Azure puts location on the resource, so multi-region needs no alias at all. Aliases are for
crossing subscriptions.
GCP also puts location on the resource; aliases are for crossing projects.
So the same intent — "the same bucket in two regions" — requires an alias on AWS and doesn't on Azure or
GCP. A secondary difference: create_before_destroy interacts with naming rules, and Azure's tighter
constraints (storage account names being globally unique, short, and alphanumeric) make coexisting old
and new instances harder to arrange.
Scenario and design
A PR removes one region from a list that a `count` iterates over. What do you say?
Ask for the plan before anything else, and expect it to show mass replacement rather than a single destroy — every index after the removed one has shifted, and if any of those resources are data-bearing this is data loss disguised as a config tidy-up.
The immediate fix is not to apply it. The correct change is to migrate the resource to for_each keyed
by region, using moved blocks so the migration itself destroys nothing, and then remove the region
as a separate commit — which will show exactly one destroy. Two PRs, and the first one's plan should
report no infrastructure changes at all.
Worth adding as a systemic point: this failure is invisible in a diff and visible only in a plan, which is the argument for making plan output a required part of review.
Design the resource block for "create N buckets, configurable per environment, some with versioning".
A for_each over a map of objects, keyed by logical name, with the per-bucket settings as object
attributes — versioning, retention, whatever else varies. Types declared explicitly, and a validation
block asserting that keys match the character set that makes a sensible resource address, since the keys
become addresses.
Then: name the buckets from "${var.name_prefix}-${each.key}" so the plan shows real names at plan
time rather than (known after apply); apply common tags from a local rather than repeating them; and
put the whole thing in a module if more than one environment consumes it, with the map passed in from
per-environment .tfvars.
What I'd avoid: count with parallel lists, which reintroduces positional identity and can't express
per-bucket settings without index arithmetic; and a for_each keyed by anything likely to be renamed.
Your plan shows every instance of a `for_each` resource being replaced, and you only edited the map's values. Diagnose.
If only values changed, addresses are stable, so replacement means something in the values is landing on an immutable attribute — most likely a name derived from the map, so changing a value changed each bucket's name.
Other candidates: the keys did change and it isn't obvious, perhaps because the map is built by a for
expression over something else that shifted; the map is keyed by an expression rather than a literal, so
a change upstream re-keyed it; or a provider upgrade started treating an attribute as
replacement-forcing.
Diagnosis is the same either way: find # forces replacement in the plan body and see which attribute
it names, then trace that attribute back to what feeds it. If names are being derived from mutable data,
the fix is to derive them from the stable key instead.
How would you protect a production database managed by Terraform from accidental destruction?
Layers, because no single control is sufficient. prevent_destroy on the resource as a tripwire,
accepting that it blocks whole-configuration destroy and can be removed by anyone in a hurry. Then the
structural controls, which matter more: production in its own configuration and its own state, so no
dev-directed apply can reach it, with apply permissions restricted to a pipeline rather than to people.
Then a policy check over plan JSON that fails the build on any delete or replace of a resource tagged
production. Then a required human approval on that environment.
And separately from Terraform entirely: backups with a tested restore, plus the cloud's own deletion
protection where it exists. The honest framing is that Terraform can prevent a plan from destroying
the database, but only backups protect the data — and a replacement forced by an immutable attribute
destroys it through a path prevent_destroy may not cover.
Commands & Gotchas
terraform state list # see the real addresses, keys and all
terraform state show 'aws_s3_bucket.this["logs"]' # quote keyed addresses in the shell
terraform plan -var 'names=["media","backups"]' # test a list change before committing it
terraform plan # after adding moved blocks: expect NO changes
terraform apply -replace='aws_s3_bucket.this["logs"]' # replace one instance deliberately
terraform state mv 'aws_s3_bucket.a[0]' 'aws_s3_bucket.b["logs"]' # imperative fallback
terraform console # evaluate a for_each map before using it
terraform show -json | jq '.values.root_module.resources[].address' # every address, programmatically
| Behaviour | Why it matters |
|---|---|
count addresses by position; for_each addresses by key |
The whole page. Named things need for_each |
Removing a middle element from a count list shifts every later index |
One-line diff, mass replacement. The most expensive mistake in Terraform |
| The instance key is part of the identity | Changing a key destroys and recreates unless you use moved |
for_each keys must be known at plan time |
Keys from variables/locals/data sources; values from anywhere |
for_each takes a map or set — never a list |
toset() a list, and then each.key == each.value |
lifecycle arguments must be literals |
No variables, so "prevent_destroy in prod only" is not expressible |
prevent_destroy blocks destroy for the entire configuration |
Breaks environment teardown; people remove it under pressure |
create_before_destroy needs the two instances to coexist |
Name collisions, and it must propagate to dependents |
ignore_changes = all means "manage existence, nothing else" |
Nearly always wrong. Ask what's actually managed |
| Instances are independent graph nodes with no edges | No ordering between them, no way to sequence one after another |
A wrong provider alias creates real resources in the wrong account |
Found in the bill, not in the plan. Check it in review |
← Back to The Machinery · Next: HCL & the Type System →
⚠️ Verification checklist (delete before publishing)
Plan output — the index-shift blocks are the most important captures in the article
-
terraform state listshowing both address styles, and confirmfor_eachaddresses are sorted by key whilecountaddresses are positional. - The count-vs-for_each comparison plan. Confirm the exact destroy/create counts —
I claim
2 to add, 0 to change, 4 to destroyfor the combined config. Recount against a real run; the arithmetic must be exactly right or the page's central argument is undermined. - Confirm
# forces replacementappears againstbucketin each shifted instance. - The
prevent_destroyerror text and format, including whether it names the instance address. - The "Invalid for_each argument" error wording for keys depending on unknown values.
- Insertion case: confirm
for_eachshows exactly one create when a key is added mid-list.
Behavioural claims — several carry interview answers
- ⚠️ Does
prevent_destroyblock the destroy half of a replacement? Flagged three times on this page because it's the case that matters most and I'm not certain. Resolve it and make all three mentions consistent. - That
lifecyclearguments cannot reference variables. Asserted twice, including in an interview answer. - That
create_before_destroymust propagate to dependents to be effective. Widely stated; verify. - That
create_before_destroycan produce a cycle error apparently unrelated to the change — same item as the dependency-graph page. Verify once, keep both consistent. -
replace_triggered_byminimum version (I wrote 1.2) andmovedblocks (I wrote 1.1). - That
ignore_changesaccepts an attribute path liketags["LastScanned"]as written. - That
one()is the right function for thecount = 0/1output idiom, and the splat syntaxaws_s3_bucket.audit_logs[*].idis correct in that position. - Whether
tflintcatches invalidfor_eachtypes — asserted in Ecosystem.
Provider-specific claims in the tab set
- AWS: multi-region genuinely requires an aliased provider — confirm there's no resource-level
region argument on
aws_s3_bucket. - Azure: that
locationon the resource means multi-region needs no alias, and that aliases are for subscriptions. Also confirm azurerm 4.x'ssubscription_idrequirement doesn't change the shape of the default provider block shown. - GCP: that aliases are for crossing projects and region rarely needs one.
- The claim that Azure's naming constraints make
create_before_destroyharder to arrange. Sounds right; verify before asserting it.
Versions and syntax
-
~> 5.0aws constraint — stale across all six pages now. One pass, all files. - The
validationblock usingalltrue([for k in keys(...) ...])— confirm it parses and thatvalidationis permitted on amap(object)variable in this form.
Structure
- One
<Tabs>block, for provider aliasing. Confirm that's the right single use — the Azure tab is much longer than the others, and the prose above it should make clear that's the point. - Length: this and the lifecycle page are the two longest in Stage 2. Check rendered length; if
over, the extraction candidate is the
lifecyclematerial, which could stand as its own topic. - All relative links resolve.