Cloud Infrastructure · Infrastructure as Code
Count, for_each, and the Stability of Instance Addresses
How a resource with a repetition argument becomes several state addresses, why index keys renumber on deletion while map keys do not, and what that costs at apply time.
A single resource block can stand for zero, one, or many remote objects. The mechanism is a repetition argument that turns the block into a template and produces one instance per element, each with its own address in state and its own entry in the plan. Two arguments do this: count, which produces instances keyed by a zero-based integer, and for_each, which produces instances keyed by a string drawn from a map or a set of strings.
Both express the same idea: the number of objects is data, not syntax. The difference between them is entirely about what the key means when the collection changes, and that difference decides whether removing one element from a list is a one-object operation or a cascade.
An instance address is a path, not a name
Terraform’s address syntax composes three parts: the module path, the resource type and name, and the instance key. A resource declared at the root as aws_instance.web with count = 3 has instances aws_instance.web[0], aws_instance.web[1], and aws_instance.web[2]. The same resource inside a module called app has addresses module.app.aws_instance.web[0] and so on. With for_each, the key is a string and the address is written aws_instance.web["blue"].
The bracket is not decoration. It is the part of the address that state uses to bind a configuration element to a remote identifier, and it is the part that changes when the collection changes. Everything else (module path, type, name) is fixed by the source text. The key is the only mutable component, which makes its stability the whole design question.
Note that the type and name are labels the author chose. Renaming the block renames every instance address at once, and the state no longer matches. That is a move operation, not a recreation, but the tool cannot infer it: the state holds the old label and the configuration holds the new one, and nothing connects them unless a moved block says so.
Why an index is a fragile key
Consider three identical web servers keyed by index, and a requirement to remove the second one. The list shrinks from three elements to two. The remaining elements shift: what was at index 2 is now at index 1. Terraform evaluates the configuration, produces instances [0] and [1], and compares them against state, which holds [0], [1], and [2].
The comparison is positional. State’s [2] has no counterpart in the new configuration, so it is destroyed. State’s [1] is matched against the new [1], whose attributes were computed from a different list element: a different subnet, a different name suffix, a different availability zone. Every attribute that differs shows up as a change, and any attribute the provider marks as requiring replacement turns the change into a destroy-and-create. One intended deletion becomes one deletion plus a rebuild of everything after it.
The cost is not only time. Each replacement is a new remote object with a new identifier, which means new IP addresses, new DNS names, new volumes, and a window in which the service is running on fewer instances than intended. If the servers sit behind a load balancer with health checks, that window is a capacity dip; if they hold state, it is data loss.
count is not wrong, it is positional. For a homogeneous set that is created and destroyed as a unit (a fixed number of identical workers, a pool whose size is the point) position is a perfectly good key, and the index is convenient for indexing into companion lists. The failure appears only when elements are removed or reordered individually.
A map key is a name the author controls
for_each over a map or a set of strings produces instances keyed by those strings. Removing an element removes exactly one key. The other keys keep their addresses, their state entries, and their remote identifiers, and the plan contains a single destroy.
locals {
bucket_names = toset(["logs", "assets", "backups"])
}
resource "aws_s3_bucket" "this" {
for_each = local.bucket_names
bucket = "example-${each.key}"
tags = { purpose = each.key }
}
Deleting "assets" from the set produces a plan with one destroy and nothing else, because logs and backups still resolve to the same addresses. Adding a name produces one create. Renaming a key destroys one instance and creates another, because the key is the identity.
The key must be known before the apply starts. for_each cannot take a set whose elements are computed by a resource that does not exist yet, because the tool needs the full key set to build the graph and the plan, and those elements would not exist until the apply was already underway. The same restriction applies to count’s argument: a count derived from a managed resource’s attribute, such as the number of subnets a VPC resource will produce, is unknown at plan time on a first run, and the plan fails with an error rather than guessing a size. The workaround is to move the derivation to a value known at plan time, such as a variable or a data source that can be read before the apply, or to split the configuration so the dependency is resolved in an earlier apply.
Keys also have to be strings, and a set must not contain duplicates. A common error is to build the set from an attribute that is unique per object but empty for some of them, which collapses several elements into one key and silently produces fewer instances than intended. Terraform reports duplicate keys as an error during plan, which is better than the alternative but arrives late relative to the author’s intent.
The interaction with replacement and ordering
Instance keys affect more than state. They participate in dependency edges: a reference to aws_s3_bucket.this["logs"].arn creates an edge to that one instance, so the dependent resource waits for that bucket only. A reference to the whole map, aws_s3_bucket.this, creates edges to every instance. The difference between depending on one instance and depending on all of them is the difference between a narrow and a wide fan-in, and in a configuration with dozens of instances it is visible in apply time.
Keys also interact with create_before_destroy, the lifecycle rule that builds the replacement before removing the original. With index keys, a replacement of [1] collides with itself: the new object must be created while the old one still occupies the same address in the provider’s namespace, and if the provider enforces a unique name, the create fails. The usual fix is to make the name depend on a value that changes when the object is replaced (a random suffix, or a hash of the configuration) so that old and new can coexist. Map keys have the same collision problem unless the name is similarly derived, so the rule is about naming, not about the key type.
Reading instances back out
Because for_each instances are keyed by the map’s keys, code that needs to consume their attributes has to decide whether it wants a map or a list. Terraform’s for expression converts between the two:
output "bucket_ids" {
value = { for k, b in aws_s3_bucket.this : k => b.id }
}
output "bucket_id_list" {
value = [for b in aws_s3_bucket.this : b.id]
}
The map form preserves the key, so a downstream resource can use for_each over it and keep the same keys end to end. The list form discards the key and reintroduces position, which is fine for a value that is consumed as a whole but not for one that will be indexed later. Chaining map-keyed resources through several layers is the discipline that keeps addresses stable across a whole configuration; converting to a list anywhere in the chain re-establishes the fragility that for_each was chosen to avoid.
Choosing between them
The decision rule is short. Use for_each when instances are distinct enough to be named and when any of them might be removed individually. Use count when the set is homogeneous and is managed as a unit, or when the configuration genuinely needs an integer index, where the first element is the primary, the rest are replicas. Use neither when there is exactly one object, because a single-element count adds a bracket to the address for no benefit and complicates every reference downstream.
There is a migration path between them, and it is not automatic. Switching a resource from count to for_each rewrites every address, so the plan proposes destroying all instances and creating all instances unless each one is moved explicitly. Terraform’s moved block handles a one-to-one rename, and for a wholesale switch the state can be manipulated instance by instance. The operation is mechanical but easy to get wrong, and the failure mode is a plan that looks plausible and destroys the entire fleet. Reviewing the plan for the expected count of destroys, rather than for the absence of errors, is the check that catches it. The same care applies when adopting objects that already exist, which is the subject of importing existing resources.
References
- Terraform Language: The count Meta-Argument — HashiCorp
- Terraform Language: The for_each Meta-Argument — HashiCorp
- Terraform Language: for Expressions — HashiCorp