count and for_each: The Index Trap, Conditionals, templatefile
In the previous article the dynamic block looped to generate nested blocks. To create multiple resources — three buckets, five instances — Terraform has two meta-arguments: count and for_each. They look equivalent when you first reach for them, but choosing wrong causes one of the most unpleasant incidents for newcomers: accidentally destroying and recreating a whole batch of resources that were running fine. This article sets up exactly that situation so you can see it firsthand, then handles it cleanly with for_each.
Goal
Understand the difference between count (by index) and for_each (by key) deeply enough to choose correctly, see count's index-shift trap, and learn two more commonly used techniques: conditional resource creation and templatefile.
count: create by index
count creates N copies of a resource, numbered from 0:
variable "names" {
type = list(string)
default = ["alpha", "beta", "gamma"]
}
resource "aws_s3_bucket" "b" {
count = length(var.names)
bucket_prefix = "tf-series-bai11-${var.names[count.index]}-"
force_destroy = true
}
count.index is the current index (0, 1, 2). Apply, then look at the addresses:
$ terraform state list
aws_s3_bucket.b[0]
aws_s3_bucket.b[1]
aws_s3_bucket.b[2]
Each bucket carries an address based on its position in the list: b[0] is alpha, b[1] is beta, b[2] is gamma. That is the root of the trap.
The trap: dropping a middle element
Suppose you no longer need the beta bucket, so you edit the list down to ["alpha", "gamma"]. Intuition says: only beta gets destroyed. Look at what Terraform plans to do:
$ terraform plan -var 'names=["alpha","gamma"]'
# aws_s3_bucket.b[1] must be replaced
~ bucket_prefix = "tf-series-bai11-beta-" -> "tf-series-bai11-gamma-" # forces replacement
# aws_s3_bucket.b[2] will be destroyed
- bucket_prefix = "tf-series-bai11-gamma-" -> null
Plan: 1 to add, 0 to change, 2 to destroy.
Read the numbers carefully: 1 to add, 2 to destroy. What happened? The new list has two elements, so the indexes are only [0] and [1]. b[0] is still alpha, no problem. But b[1], formerly beta, is now gamma — Terraform sees that bucket_prefix at position [1] changed from beta to gamma, so it replaces it. Meanwhile b[2] (the old gamma) no longer has a position and gets destroyed. The outcome: the gamma bucket that was running fine is torn down and rebuilt at a different position, even though you never meant to touch it.
For an empty bucket that's annoying; for a database or a volume holding data, this is real loss. The problem is that count identifies resources by position, and position shifts when you add or remove something in the middle.
for_each: create by stable key
for_each identifies each copy by a key instead of a position. It takes a map or a set of strings:
variable "names" {
type = set(string)
default = ["alpha", "beta", "gamma"]
}
resource "aws_s3_bucket" "b" {
for_each = var.names
bucket_prefix = "tf-series-bai11-${each.key}-"
force_destroy = true
}
each.key (and each.value) is the current element. Apply, then look at the addresses:
$ terraform state list
aws_s3_bucket.b["alpha"]
aws_s3_bucket.b["beta"]
aws_s3_bucket.b["gamma"]
The addresses are now names, not numbers. Repeat the exact same operation of dropping beta:
$ terraform plan -var 'names=["alpha","gamma"]'
# aws_s3_bucket.b["beta"] will be destroyed
# (because key ["beta"] is not in for_each map)
Plan: 0 to add, 0 to change, 1 to destroy.
0 to add, 1 to destroy. Only beta gets destroyed, because the key "beta" is no longer in the set. Alpha and gamma keep their keys, so Terraform doesn't touch them. This is the core difference.
count: identifies by POSITION for_each: identifies by KEY
─────────────────────────── ─────────────────────────────
[0] alpha ["alpha"] alpha
[1] beta ← drop beta ["beta"] beta ← drop beta
[2] gamma ["gamma"] gamma
after dropping beta: after dropping beta:
[0] alpha (kept) ["alpha"] alpha (kept)
[1] gamma (beta→gamma: REPLACED) ["gamma"] gamma (kept)
[2] — (DESTROYED) ["beta"] (DESTROYS exactly beta)
=> 2 destroy, 1 add => 1 destroy
Which to choose
The docs are explicit: use count "when you want to create roughly identical copies", use for_each "when some of an instance's arguments must take distinct values that can't be derived from an integer". The practical rule is simpler: if the copies are distinguished by a meaningful identifier (a name, a region, an environment), use for_each for stable keys; only use count for things that are truly anonymous and fixed in count. When in doubt, choose for_each — it avoids the index-shift trap. (One note: for_each doesn't automatically turn a list into a set; you must toset(...) if the input is a list.)
Conditional resource creation
count still has one valuable use: toggling a resource on or off. Since count = 0 means no copies are created, pairing it with the ternary operator gives a way to create a resource conditionally:
resource "aws_s3_bucket" "extra" {
count = var.create_extra ? 1 : 0
# ...
}
$ terraform output extra_count # create_extra = false
0
$ terraform apply -var create_extra=true && terraform output -raw extra_count
1
With create_extra = false, count = 0, nothing is created; with true, one copy is created. This is a very common pattern for enabling a feature only in prod (for example, only prod creates a cross-region backup copy).
templatefile: generate file content from variables
Sometimes you need to generate a config file from Terraform data — an nginx config, EC2 user-data, a JSON file. templatefile(path, vars) reads a template file and renders it with a set of variables. The template supports ${...} interpolation and directives such as the %{ for } loop.
The template file nginx-upstream.tftpl:
upstream backend {
%{ for addr in ip_addrs ~}
server ${addr}:${port};
%{ endfor ~}
}
Render it with a list of IPs:
locals {
rendered = templatefile("${path.module}/nginx-upstream.tftpl", {
port = 8080
ip_addrs = ["10.0.1.10", "10.0.1.11", "10.0.1.12"]
})
}
Result:
upstream backend {
server 10.0.1.10:8080;
server 10.0.1.11:8080;
server 10.0.1.12:8080;
}
The %{ for ... ~} loop generates one server line per IP, and the ~ trims excess whitespace. This is how you generate user-data and dynamic config files without manual string concatenation.
🧹 Cleanup
Every demo in this article runs terraform destroy -auto-approve right after looking at the result; the templatefile section is local-only and creates nothing on AWS.
Wrap-up
count identifies resources by position ([0], [1]), so dropping a middle element shifts the indexes and wrongly destroys-and-recreates the resources behind it — the demo showed that dropping beta caused gamma to be replaced (2 destroy). for_each identifies by stable key (["alpha"]), so the same operation destroys only the element you actually intended (1 destroy). The rule: if items are distinguished by a meaningful identifier, use for_each; when in doubt, also choose for_each; reserve count for anonymous copies and for the conditional-creation pattern count = cond ? 1 : 0. templatefile renders a file from variables using directives like %{ for }.
At this point we have enough of the language. Part IV pulls it together into something genuinely reusable: the module. Article 12 writes the first module — packaging a group of resources behind a clean input/output interface, to reuse in many places without copying code.