Skip to content
SRE & DevOpsDeep Dive Published Updated 9 min readViews unavailable

Terraform for_each: Stable Resource Identity, Safe Refactors, and Plan-Time Keys

Model Terraform instances with stable map keys, understand plan-time identity requirements, and migrate from count without accidental resource replacement.

Terraform resource instances need stable addresses so the state can associate each configuration object with the remote object it manages. The choice between count and for_each is therefore an identity decision, not just a loop syntax preference. count gives instances numeric indexes. for_each gives instances keys from a map or set of strings. Those keys appear in resource addresses, plans, state commands, imports, and refactor operations.

When a list represents individually named infrastructure, numeric indexes can accidentally couple identity to list position. Inserting or reordering an element can shift indexes and make Terraform propose replacements or destroy-and-create actions for objects that were meant to stay the same. A map keyed by durable names often makes the intended identity explicit, but it still requires careful plan review when keys are renamed or removed.

Choose keys that describe durable identity

Use count when instances are truly interchangeable and index position is an acceptable identity. A fixed set of identical workers might fit that model if the application does not care which worker has which index. Use for_each when instances have distinct names, configuration, lifecycle, or ownership. A map lets each entry carry attributes while the key remains the stable address component:

variable "services" {
  type = map(object({
    instance_type = string
    subnet_id     = string
  }))
}

resource "aws_instance" "service" {
  for_each = var.services

  instance_type = each.value.instance_type
  subnet_id     = each.value.subnet_id

  tags = {
    Name = each.key
  }
}

An instance address becomes aws_instance.service[“api”] or aws_instance.service[“worker”], not just aws_instance.service[0]. Each key should reflect a stable logical object such as a service name or environment role. Avoid keys derived from mutable display labels, resource IDs returned by a provider, list positions, timestamps, or random values. If the key changes, Terraform normally treats that as one instance disappearing and another appearing unless you explicitly declare a state move.

Map values can contain attributes that are not known until apply when the map keys are already known. Terraform needs to know the set of instance identities before it can plan remote operations. For example, a map keyed by predeclared subnet names can hold computed subnet IDs as values, but a set built from IDs that will only be returned after creating resources cannot define instances at planning time.

Plan-time requirements and sensitive values

The map keys or set members supplied to for_each must be known before Terraform performs remote resource actions. Terraform cannot plan an instance address from a value that only becomes available after apply. It also rejects sensitive values for for_each because keys and set members are disclosed in plan output and identify resources. Impure functions such as timestamp, uuid, or bcrypt cannot be used to manufacture keys whose values are deferred.

If a data flow produces both public identifiers and sensitive values, derive a map from the non-sensitive keys and look up sensitive values inside the resource body. Do not mark an entire for_each collection sensitive simply because one attribute value is secret if stable non-secret keys can be separated safely. The configuration and plan should reveal enough identity for review without printing credentials or tokens.

Terraform accepts a map or a set of strings for for_each. It does not implicitly convert a list or tuple to a set. An explicit toset conversion removes ordering and duplicate elements, so only use it when duplicates are invalid or intentionally collapse to one object:

variable "regions" {
  type = set(string)
}

resource "aws_s3_bucket" "regional_logs" {
  for_each = var.regions

  bucket = join("-", ["logs", each.value, var.account_suffix])
}

For maps, each.key identifies the map entry and each.value contains its configuration. For a set of strings, the key and value are the same string. Do not assume that sets preserve caller order; Terraform addresses instances by string value, not by position.

Count and for_each produce different addresses

With count, Terraform assigns integer indexes from zero to count minus one. If a list of three instances is used to calculate count, the list item associated with a given index can change after insertion or reordering. Terraform does not infer that index one used to mean the same logical service as index one after the list was changed. The state address is the identity it compares.

With for_each, changing an unrelated map entry does not renumber the other keys. Removing a key is an explicit request to remove that instance from configuration; Terraform plans destruction unless lifecycle or state ownership has been changed through another supported operation. Adding a key plans a new instance. Renaming a key is usually interpreted as deleting one key and adding another.

This distinction also applies to module blocks. A module with for_each gets one child module instance per key, such as module.service[“api”]. Resources inside each module instance then have addresses nested under that key. Choose module keys with the same stability discipline as resource keys. A dynamic key change at the module boundary can move a large subtree of resource addresses.

Refactor without turning a rename into recreation

When converting a count-based block to for_each, map each old numeric address to its intended new key with a moved block. This tells Terraform that the logical object remains the same while its address changes:

moved {
  from = aws_instance.service[0]
  to   = aws_instance.service["api"]
}

resource "aws_instance" "service" {
  for_each = var.services
  # ...
}

The mapping must be complete and correct for every retained instance. If one old index represented a worker and another represented an API, do not map them by accident to the opposite keys. For a pure key rename, a moved block can also map the old keyed address to the new keyed address. Review the plan and confirm that Terraform reports address moves rather than destroy-and-create operations.

Keep moved blocks long enough for every relevant workspace or state to process the refactor. Removing a moved block too early can cause a workspace that has not yet received the change to interpret its old address as a deletion. Treat state migration as a rollout across all states that use the module, not merely as a local code edit. Preserve backups and stop concurrent applies while performing manual state recovery.

Do not use a moved block to pretend two distinct remote objects are the same object. It changes Terraform’s address mapping, not the identity or contents of the remote service. Before applying a refactor, compare the old and new configuration, check provider IDs, and ensure that the remote object at the destination address is actually the object that should remain managed.

Key changes, imports, and deletions

A map key is part of the state address, so renaming a key is a state refactor. If the remote object should remain, declare a move from the old address to the new one and inspect the proposed change. If it is genuinely a different object, allow a create and destroy only after checking dependencies, data retention, and service cutover. Avoid changing keys and provider mappings in the same change unless you can explain every planned address transition.

When importing an existing object, use its complete for_each address, including the key, so the state binding lands on the intended instance. A correct import associates a remote object with a configured address; it does not prove that the chosen key is durable or that the resource configuration matches the remote object. Follow import with a plan and reconcile every unexpected difference before apply.

Removing a key from the input map normally removes that instance from desired configuration. That is often correct for a deleted service, but a typo or incomplete inventory can destroy a real service. Generate maps from reviewed inventories, validate required keys, and inspect plan counts for unexpected deletions. For critical resources, add a targeted lifecycle safeguard or deployment policy that makes accidental removal require explicit review; do not rely on a state address alone to prevent destructive intent.

Structure maps as an interface

Keep identity separate from mutable settings. The map key is the durable name; the value carries version, size, subnet, tags, and other configuration. Give the variable a precise type so callers see which attributes are required and which are optional. Validate relationships that matter, such as uniqueness or allowed naming conventions, at the module boundary. Do not put a provider-computed identifier in the key merely because it is convenient to access later.

For larger deployments, derive one well-defined map in a local value rather than repeating complicated comprehensions across resources. Build it from stable input data with deterministic keys, document where each key comes from, and keep secrets out of the identity map. Test a representative plan for add, update, rename, and removal cases so reviewers can see which operations result from each input change.

When using for_each on resources that depend on one another, reference the keyed resource map directly where possible. This preserves the dependency graph and makes key alignment visible. If two resource maps must share identity, derive both from a common source of stable keys and validate that their key sets match. Avoid silently joining unrelated lists by index, which recreates the positional identity problem under a different name.

Review plans by address, not only by totals

Before a production apply, inspect resource addresses in the plan. A total of one create and one destroy may be an intended replacement, but it may also represent an accidental key change. Compare each planned address with the source inventory, confirm provider configuration, and check whether a moved block was recognized. For a refactor, an empty infrastructure diff after address moves is often the expected result.

Run formatting, validation, and module tests from a clean checkout. Add test cases where one key is inserted, one setting changes under the same key, a key is renamed with a moved block, and a key is removed. A mocked plan test can validate local address and argument behavior; a controlled integration environment is needed to prove remote API behavior. Keep state and saved plan files protected because they can contain sensitive resource attributes.

Stable instance keys make Terraform plans easier to reason about and refactors safer. Use count for genuinely interchangeable indexed objects, use for_each when a durable logical identity exists, and treat every key change as a potentially meaningful state migration. The plan is the proof that the intended identity relationship was preserved.

Related:

Sources:

Comments