Meta-Arguments: count, for_each and depends_on

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Almost every real configuration needs to create "several of the same thing": three replicas of catalog-api, one container per service, one bucket per environment. HCL has no loop statement, so Terraform solves the problem with meta-arguments: arguments that are understood by Terraform core and are available on every resource, data source and module regardless of the provider. This page covers the three that shape the dependency graph and the number of instances, count, for_each and depends_on, together with provider and providers, and it explains the part that surprises people the most: how each instance is identified, because that identity decides what Terraform destroys when a list or map changes. It describes Terraform 1.17 and OpenTofu 1.13.

The page follows Resources and Data Sources in the reading path, where the lifecycle of a single resource instance is explained, and it prepares Lifecycle, Validation and Checks (the lifecycle meta-argument), Modules (where count and for_each repeat a whole module) and The Dependency Graph and the Plan, which shows how the dependencies declared here become graph edges. The for expressions and dynamic blocks that often accompany these meta-arguments are described in Types and Expressions.

All examples use the Bookshelf scenario (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers bucket). They run locally with the kreuzwerker/docker and hashicorp/random providers, so no cloud account is needed.

The meta-arguments at a glance

A meta-argument is an argument that does not belong to the provider’s schema. The provider decides which arguments a docker_container accepts; Terraform core decides which meta-arguments any block accepts. Because core handles them, they behave the same for every provider, and a provider can never override them.

Meta-argument Available in Purpose

count

resource, data, ephemeral, module (and list query blocks, and action blocks)

Create a number of instances given by a whole number. Each instance is identified by an index and count.index exposes it.

for_each

resource, data, ephemeral, module (and list query blocks, and action blocks)

Create one instance per element of a map or a set of strings. Each instance is identified by a key and the each object exposes it.

depends_on

resource, data, ephemeral, module, output, check

Declare a dependency that Terraform cannot infer from references.

provider

resource, data, ephemeral, import, check and list blocks

Choose an alternate (aliased) provider configuration for a single block.

providers

module

Map provider configurations of the calling module to the provider names used inside the child module.

lifecycle

resource and data

Customize how Terraform treats an object: create_before_destroy, prevent_destroy, ignore_changes, replace_triggered_by, precondition and postcondition. Covered in Lifecycle, Validation and Checks.

A single block cannot use both count and for_each. In OpenTofu the same restriction extends to the enabled argument (see [terraform-and-opentofu-differences]). Meta-arguments are not variables: their values are evaluated while Terraform builds the graph, and that restriction explains most of the limitations described later.

count: a number of identical instances

count takes a whole number and creates that many instances of a resource, data source or module. The instances are numbered from zero, and inside the block count.index holds the number of the instance being configured.

terraform {
  required_version = ">= 1.15"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
  }
}

variable "catalog_api_replicas" {
  type        = number
  description = "Number of catalog-api containers to run."
  default     = 3

  validation {
    condition     = var.catalog_api_replicas >= 0 && floor(var.catalog_api_replicas) == var.catalog_api_replicas
    error_message = "The number of replicas must be a whole number greater than or equal to zero."
  }
}

resource "docker_image" "catalog_api" {
  name         = "nginx:1.27-alpine"
  keep_locally = true
}

resource "docker_container" "catalog_api" {
  count = var.catalog_api_replicas

  name  = "catalog-api-${count.index}"
  image = docker_image.catalog_api.image_id

  ports {
    internal = 80
    external = 8080 + count.index
  }
}

With the default of three replicas the plan creates three containers (catalog-api-0, catalog-api-1 and catalog-api-2) listening on ports 8080, 8081 and 8082. The block as a whole is addressed as docker_container.catalog_api, which is now a list of objects, and each instance is addressed with its index: docker_container.catalog_api[0].

The count object

Inside a block that uses count there is exactly one extra named value: count.index, a zero-based number. It can be used in any argument of the block, including nested blocks, provisioner blocks and connection blocks (where self refers to the instance). count.index is not available outside the block, and other blocks reach an instance only through its address.

Referencing the instances

A block that uses count is a list in expressions, so the usual list syntax applies.

output "catalog_api_names" {
  description = "Names of every catalog-api container."
  value       = docker_container.catalog_api[*].name
}

output "first_catalog_api_name" {
  description = "Name of the first catalog-api container."
  value       = docker_container.catalog_api[0].name
}

output "catalog_api_ports" {
  description = "Host ports of every catalog-api container."
  value       = [for c in docker_container.catalog_api : c.ports[0].external]
}

docker_container.catalog_api[*].name is the splat form of the for expression in the last output. When catalog_api_replicas is zero the list is empty and both forms return an empty list, but docker_container.catalog_api[0].name fails because the instance does not exist. That is why the "zero or one" idiom below always indexes with care.

count as a switch

The most common use of count is not repetition at all but a conditional resource: a value of 1 creates the resource and 0 removes it.

variable "enable_redis" {
  type        = bool
  description = "Whether the Bookshelf stack runs a Redis cache."
  default     = true
}

resource "docker_image" "redis" {
  count = var.enable_redis ? 1 : 0

  name         = "redis:7-alpine"
  keep_locally = true
}

resource "docker_container" "redis" {
  count = var.enable_redis ? 1 : 0

  name  = "bookshelf-redis"
  image = docker_image.redis[0].image_id
}

output "redis_container_id" {
  description = "Identifier of the Redis container, or null when Redis is disabled."
  value       = one(docker_container.redis[*].id)
}

Two details make this idiom comfortable. A resource that is behind count must always be indexed, even when only one instance can exist (docker_image.redis[0]), and a resource that depends on a conditional one must be conditional too, otherwise the reference docker_image.redis[0] fails when Redis is disabled. The one() function converts the list of zero or one elements into either null or the single object, which keeps outputs from exploding. OpenTofu 1.11 added a dedicated enabled argument inside lifecycle for exactly this case (see [terraform-and-opentofu-differences]).

What count accepts

  • A whole number. A non-integer, a negative value or a non-number is an error. A numeric string such as "3" is converted to a number when it is a valid numeric string.

  • A value that is known during planning. A literal, a variable, a local or a function of those is fine. A value that comes from another resource’s computed attribute is not (see Unknown values: known only after apply).

  • A value that is not sensitive in the sense that matters for for_each below: a count that depends on a sensitive variable is accepted, because only a number is shown, but anything that derives the number from a sensitive collection leaks its length in the plan.

  • 0, which removes every instance and keeps the block in the configuration.

count = length(var.services) is a very common way to repeat a block once per element of a list, and it is exactly where count becomes dangerous; the next sections explain why.

for_each: one instance per key

for_each takes a map or a set of strings and creates one instance per element. Each instance is identified by a key: the map key, or the string itself for a set. Inside the block the each object exposes the current element.

variable "services" {
  type = map(object({
    image         = string
    internal_port = number
    external_port = number
  }))
  description = "Bookshelf application services, keyed by service name."

  default = {
    catalog-api = {
      image         = "nginx:1.27-alpine"
      internal_port = 80
      external_port = 8081
    }
    reviews-api = {
      image         = "nginx:1.27-alpine"
      internal_port = 80
      external_port = 8082
    }
    web = {
      image         = "nginx:1.27-alpine"
      internal_port = 80
      external_port = 8080
    }
  }
}

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

  name         = each.value.image
  keep_locally = true
}

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

  name  = "bookshelf-${each.key}"
  image = docker_image.service[each.key].image_id

  ports {
    internal = each.value.internal_port
    external = each.value.external_port
  }
}

The instances are addressed by key, not by position: docker_container.service["catalog-api"], docker_container.service["reviews-api"] and docker_container.service["web"]. Adding a fourth entry to the map adds one container and touches nothing else. Removing reviews-api destroys exactly that container, whatever its position in the file.

The each object

Reference Value

each.key

The map key, or the set member, of the instance being configured.

each.value

The map value of the instance. For a set of strings it is equal to each.key.

each is only defined inside the block that declares for_each. If a nested dynamic block needs the outer each, capture it first in a locals value or give the dynamic block an explicit iterator, because dynamic blocks bring their own iterator named after the block label. Within provisioner and connection blocks self refers to the instance and not to the whole resource.

Sets of strings and toset

A set is the natural input when there is nothing to say about an element except its name, such as a list of bucket or queue names. HCL has no set literal, so a list is converted with toset().

variable "service_names" {
  type        = list(string)
  description = "Names of the Bookshelf services that need a data volume."
  default     = ["catalog-api", "reviews-api", "web"]
}

resource "docker_volume" "data" {
  for_each = toset(var.service_names)

  name = "bookshelf-${each.key}-data"
}

for_each does not convert a list or a tuple implicitly. Passing var.service_names directly is an error that says the argument must be a map or set of strings. toset() discards both duplicates and ordering, which is precisely the behaviour you want for identity: reordering the list does not change a single address. A variable declared as set(string) avoids the conversion altogether. When each instance needs different arguments, use a map (as in the previous example) and carry the per-instance data in each.value.

Deriving a map with a for expression

Often the source data is a list of objects. A for expression turns it into a map whose keys are stable names.

locals {
  databases = [
    { name = "catalog", owner = "catalog_api" },
    { name = "reviews", owner = "reviews_api" },
  ]

  databases_by_name = { for d in local.databases : d.name => d }
}

resource "random_password" "database" {
  for_each = local.databases_by_name

  length  = 24
  special = false
}

output "database_owners" {
  description = "Database owner of every Bookshelf database, keyed by database name."
  value       = { for name, db in local.databases_by_name : name => db.owner }
}

The key must be unique: the for expression above fails if two objects share a name, which is a useful validation in disguise. Choose keys that describe the thing and do not change (a service name, a region, an environment), not keys built from an attribute that may be edited later, because the key is the identity of the instance.

Chaining for_each

A resource that uses for_each over another resource that uses for_each inherits its keys. The container resource above reads docker_image.service[each.key]. The same idea works with the whole resource, since a block with for_each is a map of instances:

resource "docker_container" "sidecar" {
  for_each = docker_container.service

  name  = "${each.value.name}-sidecar"
  image = docker_image.service[each.key].image_id
}

Chaining is safe because the keys of docker_container.service are known at plan time, and each instance of the sidecar lives and dies with its counterpart. Only the keys have to be known before apply; each.value may contain attributes that are known only after apply, such as each.value.id. This distinction is the root of the workarounds in Unknown values: known only after apply.

Nested collections with flatten

Two levels of repetition, such as "every database of every service", cannot be expressed with a single for_each over a nested structure. Flatten the combinations into one list of objects, give each one a composite key, and iterate over the resulting map.

locals {
  service_databases = {
    catalog-api = ["catalog", "catalog_audit"]
    reviews-api = ["reviews"]
  }

  database_grants = flatten([
    for service, databases in local.service_databases : [
      for database in databases : {
        key      = "${service}.${database}"
        service  = service
        database = database
      }
    ]
  ])
}

resource "terraform_data" "grant" {
  for_each = { for g in local.database_grants : g.key => g }

  input = "GRANT ALL ON DATABASE ${each.value.database} TO ${each.value.service}"
}

The instances are terraform_data.grant["catalog-api.catalog"], terraform_data.grant["catalog-api.catalog_audit"] and terraform_data.grant["reviews-api.reviews"]. For a full cross product of two independent collections setproduct() returns every pair, and the same composite key technique applies. The functions are introduced in Functions.

for_each on data sources and modules

for_each and count are available on data sources exactly as on resources, and on module blocks (see Meta-arguments on modules). A data source with for_each is read once per key, which is how a configuration looks up many objects with one block.

data "docker_network" "lookup" {
  for_each = toset(["bridge", "host"])

  name = each.key
}

output "network_ids" {
  description = "Identifier of each pre-existing Docker network that was looked up."
  value       = { for name, n in data.docker_network.lookup : name => n.id }
}

Instance addresses

An address names an object in the configuration and in state. Addresses appear in the plan, in error messages, in the output of terraform state list, and as arguments to commands such as -target, -replace, terraform state mv and terraform import. The rules are simple, and they are the key to understanding everything else on this page.

Block Address of the block and of its instances

Without count or for_each

docker_container.redis

With count

docker_container.catalog_api (the list), docker_container.catalog_api[0] (an instance)

With for_each

docker_container.service (the map), docker_container.service["web"] (an instance)

Data source

data.docker_network.lookup["bridge"]

Module

module.network, module.network[0], module.network["staging"]

Resource inside a module instance

module.network["staging"].docker_network.main

The index is part of the identity. docker_container.catalog_api[1] is a different object from docker_container.catalog_api[2] as far as state is concerned, even when they are configured almost identically. This is the whole story behind the next section.

Reading addresses in state

terraform state list prints one address per line, which makes it the quickest way to see which instances Terraform believes exist. The output below shows the Bookshelf stack of this page after applying the count and for_each examples.

$ terraform state list
data.docker_network.lookup["bridge"]
data.docker_network.lookup["host"]
docker_container.catalog_api[0]
docker_container.catalog_api[1]
docker_container.catalog_api[2]
docker_container.redis[0]
docker_container.service["catalog-api"]
docker_container.service["reviews-api"]
docker_container.service["web"]
docker_image.catalog_api
docker_image.redis[0]
docker_image.service["catalog-api"]
docker_image.service["reviews-api"]
docker_image.service["web"]

$ terraform state list 'docker_container.service'
docker_container.service["catalog-api"]
docker_container.service["reviews-api"]
docker_container.service["web"]

$ terraform state show 'docker_container.service["web"]'
# docker_container.service["web"]:
resource "docker_container" "service" {
    id    = "0123456789ab..."
    name  = "bookshelf-web"
    ...
}

Quoting matters in a shell. The double quotes inside a for_each address are consumed by the shell unless the whole address is in single quotes, as above (or each inner quote is escaped with a backslash). In PowerShell and cmd.exe the rules differ again, and the official documentation lists the variants. A bare address such as docker_container.service selects every instance of the block.

Targeting instances

The -target option of plan, apply and destroy restricts an operation to one address and the objects it depends on. The address may be a whole block or one instance.

$ terraform plan -target='docker_container.service["web"]'
$ terraform apply -target=docker_container.catalog_api
$ terraform apply -target='module.network["staging"]'

-target is meant for exceptional situations: recovering from an error, or working around an unknown value (see below). Terraform prints a warning after a targeted plan because the rest of the configuration was ignored, so the state can legitimately differ from the configuration afterwards. A routine workflow that depends on -target is a sign that the configuration should be split into smaller root modules. OpenTofu also offers -exclude, the inverse option, which plans everything except the given address and its dependents. The planning options are described in The Dependency Graph and the Plan and the commands in CLI Essentials.

Why for_each is safer than count

A count instance is identified by its position. As long as the list only grows at the end, positions are stable. The moment an element is removed from the middle, every element after it moves one position to the left, and Terraform sees that as a series of changes rather than as the removal of one element.

variable "queue_names" {
  type    = list(string)
  default = ["catalog-events", "reviews-events", "web-events"]
}

resource "terraform_data" "queue_by_count" {
  count = length(var.queue_names)

  input = var.queue_names[count.index]
}

resource "terraform_data" "queue_by_key" {
  for_each = toset(var.queue_names)

  input = each.key
}

Suppose reviews-events is deleted from the list. The diagram shows what happens in each variant.

flowchart LR subgraph COUNT["count: identity is the index"] direction TB C0["queue_by_count[0] = catalog-events"] --> C0N["queue_by_count[0] = catalog-events (no change)"] C1["queue_by_count[1] = reviews-events"] --> C1N["queue_by_count[1] now = web-events (update or replace)"] C2["queue_by_count[2] = web-events"] --> C2N["queue_by_count[2] destroyed"] end subgraph FOREACH["for_each: identity is the key"] direction TB F0["queue_by_key[catalog-events]"] --> F0N["no change"] F1["queue_by_key[reviews-events]"] --> F1N["destroyed"] F2["queue_by_key[web-events]"] --> F2N["no change"] end

With count the plan shows an in-place update (or, for arguments that force replacement, a replacement) of queue_by_count[1], which now has to become the web-events queue, and a destruction of queue_by_count[2]. Nothing in the plan says "delete reviews-events": Terraform has no way to know that the removed element was the one in the middle. For a message queue the consequence might be a few seconds of renaming; for a database, a disk or a Kubernetes namespace it can be data loss. With for_each the plan is exactly what a human would expect: one destruction, at queue_by_key["reviews-events"], and no other change.

$ terraform plan
...
  # terraform_data.queue_by_count[1] must be replaced
-/+ resource "terraform_data" "queue_by_count" {
      ~ id     = "..." -> (known after apply)
      ~ input  = "reviews-events" -> "web-events" # forces replacement
    }

  # terraform_data.queue_by_count[2] will be destroyed
  # (because index [2] is out of range for count)
  - resource "terraform_data" "queue_by_count" {
      - input = "web-events" -> null
    }

  # terraform_data.queue_by_key["reviews-events"] will be destroyed
  # (because key ["reviews-events"] is not in for_each map)
  - resource "terraform_data" "queue_by_key" {
      - input = "reviews-events" -> null
    }

Plan: 1 to add, 1 to destroy ... (abbreviated)

The comments because index [2] is out of range for count and because key ["reviews-events"] is not in for_each map are what Terraform really prints next to the destroy action, and they are worth learning to read in a plan: they tell you which of the two mechanisms is responsible.

A rule of thumb

Question count for_each

What identifies an instance?

Its position in a sequence.

A key chosen by you.

Removing an element from the middle

Shifts every later instance and can update or replace them.

Destroys only the removed instance.

Reordering the input

Reorders the instances and can change them.

No effect on the instances.

Instances differ in their arguments

Awkward: every difference is derived from count.index.

Natural: the map value carries the differences.

Zero or one instance (a switch)

count = var.enabled ? 1 : 0, idiomatic.

for_each = var.enabled ? toset(["this"]) : toset([]) works but is clumsier.

Value required to be known during plan

A whole number.

Every key of the map or element of the set.

Typical use

A fixed number of identical, interchangeable replicas; a conditional resource.

Anything with a name: services, buckets, users, regions, environments.

The documentation itself gives the same advice: use count when the instances are nearly identical, and use for_each when some of their arguments must have distinct values that cannot be derived from an integer. In practice, default to for_each whenever the instances have names, and keep count for the on/off switch and for truly interchangeable replicas whose number only ever grows or shrinks at the end.

Moving from count to for_each

Changing an existing resource from count to for_each changes every address, so a plain edit makes Terraform plan to destroy all the old instances and create all the new ones. The state can be rewritten without touching the infrastructure by declaring a moved block for each instance.

moved {
  from = terraform_data.queue_by_count[0]
  to   = terraform_data.queue_by_key["catalog-events"]
}

moved {
  from = terraform_data.queue_by_count[1]
  to   = terraform_data.queue_by_key["reviews-events"]
}

moved {
  from = terraform_data.queue_by_count[2]
  to   = terraform_data.queue_by_key["web-events"]
}

The plan then reports has moved to lines instead of destroy and create actions. The older command-line equivalent is terraform state mv with the same addresses, which edits the state immediately and cannot be reviewed in a pull request. Refactoring, including the removed block, is the subject of Refactoring: moved and removed.

Unknown values: known only after apply

Both count and for_each decide how many instances exist, and Terraform needs that decision before it can build the rest of the graph, because every instance is a separate node and every node must be planned. So the value of count and the keys of for_each must be known during planning. Values that are "known after apply", such as the id of a resource that does not exist yet, cannot be used.

The errors

When the rule is violated, the plan stops with an error that names the argument and says that it depends on a value that cannot be determined until apply. The text varies a little between versions; these are the usual shapes.

$ terraform plan
╷
│ Error: Invalid for_each argument
│
│   on main.tf line 23, in resource "docker_container" "per_network":
│   23:   for_each = toset(docker_network.created[*].id)
│     ├────────────────
│     │ docker_network.created is a list of object, known only after apply
│
│ The "for_each" set includes values derived from resource attributes that
│ cannot be determined until apply, and so Terraform cannot determine the
│ full set of keys that will identify the instances of this resource.
│
│ When working with unknown values in for_each, it's better to use a map
│ value where the keys are defined statically in your configuration and where
│ only the values contain apply-time results.
│
│ Alternatively, you could use the -target planning option to first apply only
│ the resources that the for_each value depends on, and then apply a second
│ time to fully converge.
╵

For count the message is Invalid count argument, with the sentence The "count" value depends on resource attributes that cannot be determined until apply, so Terraform cannot predict how many instances will be created. To work around this, use the -target argument to first apply only the resources that the count depends on. The wording above is reproduced from the behaviour of recent releases in abbreviated form; the documentation pages describe the condition ("dependencies that cannot be determined before apply") without quoting the message, so treat the exact text as indicative.

Why the keys, and only the keys

The distinction that resolves most cases is between the keys of a collection and its values. A for_each map may contain unknown values: they just have to be associated with keys that are known. Compare the two configurations below.

resource "docker_network" "created" {
  for_each = toset(["frontend", "backend"])

  name = "bookshelf-${each.key}"
}

# Fails: the set of keys is built from ids that only exist after apply.
resource "terraform_data" "bad" {
  for_each = toset([for n in docker_network.created : n.id])

  input = each.key
}

# Works: the keys come from the network keys, the ids stay in the values.
resource "terraform_data" "good" {
  for_each = { for key, n in docker_network.created : key => n.id }

  input = each.value
}

In good, Terraform knows during planning that two instances exist, terraform_data.good["frontend"] and terraform_data.good["backend"], because the keys of docker_network.created are the static strings from the toset() literal. The input is "known after apply", and that is fine.

Workarounds

Situation Workaround

The keys come from an attribute of another resource

Build the map from keys that are static (names, indexes of a static list, the keys of another for_each resource) and keep the apply-time attribute in the value, as in terraform_data.good above.

The number of instances is derived from a collection that another resource produces

Derive the number from the input of that resource (the variable or local that feeds it) instead of from its output. If the other resource has count = length(var.x), then length(var.x) is also known here.

A value really is only available after another object exists

Split the work in two: apply the dependency first with terraform apply -target=ADDRESS, then apply the whole configuration. This is the fallback the error message itself suggests, and it should be a one-off recovery, not a routine step.

The dependency is a different component of the platform (a network, a cluster)

Put it in a separate root module and read it back with a data source or terraform_remote_state (or, better, a data source of the provider). The values are then known when the second configuration plans.

The value comes from uuid(), bcrypt() or timestamp()

Impure functions are evaluated late and cannot supply keys. Use a stable name, or random_id / random_pet resources and chain on their keys.

The collection contains sensitive values

Sensitive input variables, outputs and attributes are rejected because keys appear in the plan output. Build the keys from non-sensitive data: toset([for k, v in local.map : k]), and keep the sensitive value on the map side.

A frequent trap is count = length(aws_subnet.private[*].id) or for_each = data.some_thing.all.ids over a data source whose result depends on a resource in the same configuration: as soon as the data source’s read is deferred until apply, every consumer fails with the error above. The remedy is the same: derive the repetition from inputs the configuration already knows. Data sources whose configuration is fully known are read during planning (see Resources and Data Sources), and a data source is deferred only when its arguments are unknown or when depends_on names a resource with pending changes.

Sensitive values

Keys appear in the plan, in state and in the UI, so Terraform refuses them if they are sensitive. A sensitive map can still drive for_each if the keys are made non-sensitive explicitly.

variable "service_tokens" {
  type      = map(string)
  sensitive = true
}

resource "terraform_data" "token_holder" {
  for_each = toset(nonsensitive(keys(var.service_tokens)))

  input = sensitive(var.service_tokens[each.key])
}

Calling nonsensitive() is a promise that the names themselves are not secret. Do not use it on values whose disclosure matters. OpenTofu 1.8 improved the error message for a for_each over a sensitive value of an unsuitable type.

Deferred actions (experimental)

Terraform 1.17 contains an experimental "deferred actions" feature, available only in alpha builds and enabled with terraform plan -allow-deferral. It lets count and for_each have unknown values by deferring the dependent objects to a later plan, and lets providers react to unknown values. It is not in stable releases, so stable configurations must still follow the rules above.

depends_on and hidden dependencies

Terraform orders operations by reading the references between blocks. If a container reads docker_image.catalog_api.image_id, the image is built first. Those are implicit dependencies, they are the overwhelming majority, and they are the reason a configuration does not have to say what runs first. The full mechanics are in The Dependency Graph and the Plan.

A hidden dependency is a relationship that exists in the real world but not in the arguments. A classic example: catalog-api reads a table that only exists after a schema migration, but nothing in its arguments mentions the migration. The depends_on meta-argument adds an explicit edge for such cases.

resource "docker_network" "bookshelf" {
  name = "bookshelf"
}

resource "docker_container" "postgres" {
  name  = "bookshelf-postgres"
  image = "postgres:16-alpine"

  env = ["POSTGRES_PASSWORD=${random_password.database["catalog"].result}"]

  networks_advanced {
    name = docker_network.bookshelf.name
  }
}

# The migration job needs the database to be running, but it reaches it by network name,
# so no argument references the container.
resource "terraform_data" "schema_migration" {
  input = "migrate-catalog-schema-v1"

  depends_on = [docker_container.postgres]

  provisioner "local-exec" {
    command = "echo Running migrations against bookshelf-postgres"
  }
}

The rules

  • The value is a list of references to resources, data sources or child modules in the same module. A reference to a whole block with count or for_each (docker_container.service, without an index) depends on all its instances. Arbitrary expressions, function calls and references to variables or locals are not allowed, because the dependencies must be known before expressions can be evaluated.

  • Terraform completes all actions on the dependency, including reads of data sources, before acting on the dependent object. When the dependency is a module, it affects every resource and data source in that module.

  • The meta-argument is available in resource, data, ephemeral, module, output and check blocks, and in Stacks component blocks.

  • Cycles are errors: if a depends on b and b depends on a, Terraform reports the cycle at plan time and lists the nodes involved.

Why it is a last resort

The documentation calls depends_on a last resort for a reason. With a reference such as docker_image.catalog_api.image_id, Terraform can tell that the dependent object only needs to change when that one attribute changes. With depends_on, the dependent object must be treated as "possibly affected by anything" in the dependency, so the plan tends to be more conservative: more values show as "known after apply", and more objects are planned for update or replacement than strictly necessary. The effect is largest with a whole module as dependency.

Prefer, in this order:

  1. a real reference (use the dependency’s output in an argument, even a dummy one);

  2. a data source that reads the dependency;

  3. depends_on with a comment explaining why the dependency is hidden.

resource "docker_container" "web" {
  name  = "bookshelf-web"
  image = "nginx:1.27-alpine"

  # Hidden dependency: web proxies to both APIs by DNS name on the shared network, so the
  # containers must exist before web starts. No argument of web reads them.
  depends_on = [
    docker_container.service,
  ]
}

Always write a comment next to a depends_on: it is the only record of why the edge exists, and without it the next maintainer cannot tell whether it can be deleted.

depends_on and data sources

A data source is normally read during the plan when its arguments are known, so that the plan can use the result. If a data source declares depends_on on a resource that has pending changes, the read is deferred until the apply, because the dependency must complete first, and every value derived from the data source becomes "known after apply". That can in turn trigger the unknown-value errors from the previous section when the result is used in count or for_each. Therefore, give a data source a depends_on only when it genuinely needs to see changes made during the same run, and prefer arguments that reference the dependency, so that Terraform knows when the read is safe.

The same meta-argument is how a check waits for infrastructure to exist. A data block nested in a check block may use depends_on so the assertion is evaluated after another resource has been created, and until that resource exists Terraform shows a "known after apply" warning rather than a failure.

check "web_is_healthy" {
  data "http" "web" {
    url = "http://localhost:8080/"

    depends_on = [docker_container.web]
  }

  assert {
    condition     = data.http.web.status_code == 200
    error_message = "The web container did not answer with HTTP 200."
  }
}

The http data source belongs to the hashicorp/http provider, declared in required_providers. If the check references the resource directly in its arguments, every change to the resource produces the warning, which makes the check noisy; depends_on is the better choice when the data block does not otherwise reference the resource. Checks are described in Lifecycle, Validation and Checks.

depends_on in outputs

An output rarely needs depends_on, because it normally references the values it exposes. When an output must wait for something that is not part of its value (for example, a module output that is only meaningful once a policy attachment has completed), add depends_on and say why in a comment. The output page covers this in Variables, Outputs and Locals.

depends_on and replacement

A resource that depends on another one is not necessarily replaced when the other one is replaced. Dependencies control ordering, and the changes are driven by differences in arguments. If a container must be recreated when a configuration file changes, a reference alone is not enough; the replace_triggered_by lifecycle argument expresses that relationship explicitly, and it is described in Lifecycle, Validation and Checks.

provider and providers

A resource uses the default configuration of the provider named by the first word of its type: docker_container uses the default docker provider. A provider block with an alias creates an alternate configuration, and the provider meta-argument selects it for a single block. A module receives configurations through providers.

provider on resources and data sources

terraform {
  required_version = ">= 1.15"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
  }
}

provider "docker" {
  host = "unix:///var/run/docker.sock"
}

provider "docker" {
  alias = "remote"
  host  = "ssh://deploy@staging.example.internal"
}

resource "docker_image" "local" {
  name = "nginx:1.27-alpine"
}

resource "docker_image" "staging" {
  provider = docker.remote

  name = "nginx:1.27-alpine"
}

The value is a reference of the form PROVIDER.ALIAS without quotes. It must be a static reference: Terraform needs it while building the graph, so arbitrary expressions, variables and conditionals are not permitted. This is why a single resource with for_each cannot pick a different provider per key in Terraform. Each resource implicitly depends on its provider, which is fully configured before any action on the resource runs. The provider argument is also valid in data, ephemeral, import, check and list blocks; a list block does not get the implicit default and needs it explicitly.

Everything about provider configuration itself, aliases, authentication and multi-region setups is in Providers.

providers on modules

A child module inherits every default (non-aliased) provider configuration of its caller. It never inherits aliased configurations automatically. The providers argument of a module block changes this: it maps each provider name used inside the module to a configuration of the caller.

module "service_stack_local" {
  source = "./modules/service-stack"

  # No providers argument: the child uses the default docker configuration.
}

module "service_stack_staging" {
  source = "./modules/service-stack"

  providers = {
    docker = docker.remote
  }
}

Specifying providers cancels the default behavior: the child only receives the configurations listed. When a module needs two configurations of the same provider (for example a source and a destination region), it declares them with configuration_aliases in its own required_providers, and the caller maps both.

# modules/replicated-bucket/versions.tf
terraform {
  required_providers {
    docker = {
      source                = "kreuzwerker/docker"
      version               = "~> 4.6"
      configuration_aliases = [docker.source, docker.destination]
    }
  }
}

# root module
module "covers_sync" {
  source = "./modules/replicated-bucket"

  providers = {
    docker.source      = docker
    docker.destination = docker.remote
  }
}

Two rules from modules matter here. Child modules should not contain their own provider blocks: a module with its own provider configuration cannot be used with count, for_each or depends_on, and cannot be removed cleanly because the provider configuration disappears together with the module. Keep provider blocks in the root module and pass them down. In Terraform the providers map is static, like provider; you cannot compute it.

Meta-arguments on modules

The module block accepts count, for_each, depends_on and providers in addition to its own source and version. They work like their resource counterparts, applied to an entire subtree of resources. The module system is described in Modules.

count and for_each on a module

module "environment" {
  source = "./modules/bookshelf-environment"

  for_each = {
    dev     = { replicas = 1 }
    staging = { replicas = 2 }
  }

  name     = each.key
  replicas = each.value.replicas
}

output "environment_urls" {
  description = "URL of every environment."
  value       = { for name, m in module.environment : name => m.url }
}

Every resource inside the module gets the module instance as a prefix: module.environment["dev"].docker_network.main and module.environment["staging"].docker_network.main. Referencing outputs works as with a resource: module.environment["dev"].url for one instance, or a for expression over module.environment for all of them. The same rules apply as on resources: count and for_each cannot be used together, must be known during planning, and the instance identity is the index or key. Removing one key destroys one environment.

depends_on on a module

A module-level depends_on makes every resource and data source inside the module wait for the dependency. It is rarely needed and expensive, for the reasons given above, because any change to the dependency can cascade to every object in the module.

module "web_frontend" {
  source = "./modules/web"

  image = "nginx:1.27-alpine"

  # Hidden dependency: the web module reaches the APIs by name through a shared network
  # that the environment module owns. Nothing in the inputs refers to the APIs.
  depends_on = [module.environment]
}

A cheaper alternative is to pass something from the dependency as an input, such as the network name, and use it in the child module. That creates a precise reference instead of a blanket ordering.

Restrictions that surprise people

  • A module that contains provider blocks cannot use count, for_each or depends_on.

  • The source and version arguments of a module block must be known when terraform init runs, so count and for_each cannot vary the source per instance. Since Terraform 1.15 const variables and locals can appear in source and version; OpenTofu has allowed statically evaluated variables there since 1.8. See Variables, Outputs and Locals.

  • Module outputs may be unknown at plan time, so they cannot drive another count or for_each unless the keys are independent of apply-time values.

  • A resource inside a repeated module cannot be addressed with -target without the full module instance in the address: module.environment["dev"].docker_network.main.

The lifecycle meta-argument

lifecycle is the sixth meta-argument and the one that changes how Terraform treats a resource rather than how many instances exist: create_before_destroy, prevent_destroy, ignore_changes, replace_triggered_by, precondition and postcondition. It interacts with this page in two ways. prevent_destroy blocks the destroy action that a removed for_each key or a shrinking count would otherwise cause, and replace_triggered_by lets one instance follow another. Everything else is in Lifecycle, Validation and Checks.

Terraform and OpenTofu differences

The meta-arguments are the same in both tools, and the examples above run unchanged with tofu. OpenTofu adds a few features around them.

Topic Terraform OpenTofu

count, for_each, depends_on on resources and modules

As described above.

The same semantics, documented on its own pages.

Zero-or-one instance

count = var.enabled ? 1 : 0 and indexing with [0].

Same, and since 1.11 lifecycle { enabled = var.enabled }. The resource is a singleton (no index) and evaluates to null when disabled. enabled cannot be combined with count or for_each in the same block, and its value cannot be unknown, sensitive, null or ephemeral.

for_each in provider blocks

Not available. A provider configuration is a single instance, and provider / providers take static references.

Since 1.9, an aliased provider block accepts for_each, and a resource chooses an instance with provider = aws.by_region[each.key]. The default configuration cannot use for_each.

Variables and locals in module sources and backends

Const variables and locals in module source / version since 1.15.

Statically evaluated variables and locals in module sources and backend configuration since 1.8.

Targeting

-target for plan, apply and destroy.

-target and the inverse -exclude.

Unknown count / for_each

An error; the experimental deferred actions (-allow-deferral, alpha builds) lift it.

An error with the same remedies (use static keys, or -target / -exclude).

for_each in OpenTofu provider blocks

terraform {
  required_version = ">= 1.9.0"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
  }
}

variable "hosts" {
  type        = map(string)
  description = "Docker daemon address of each Bookshelf host, keyed by environment."
  default = {
    dev     = "unix:///var/run/docker.sock"
    staging = "ssh://deploy@staging.example.internal"
  }
}

provider "docker" {
  alias    = "by_env"
  for_each = var.hosts
  host     = each.value
}

resource "docker_image" "nginx" {
  for_each = var.hosts
  provider = docker.by_env[each.key]

  name = "nginx:1.27-alpine"
}

The provider reference is static, but the key expression inside the square brackets may be dynamic. All instances of one resource must bind to the same provider block, though each may use a different instance of it. The provider’s for_each should be a superset of the resource’s: removing a provider instance that still manages resources in state is an error, so remove the resources first (for example with a targeted destroy), or keep the provider’s collection larger and filter the resource’s collection instead. A child module receives such a provider through its providers argument, and declares the aliases it expects with configuration_aliases.

What changed recently

Version Change

Terraform 0.12 and 0.13

for_each for resources (0.12.6) and for modules (0.13), and count / for_each / depends_on on modules (0.13). These are the versions most older tutorials and books predate.

OpenTofu 1.8

Variables and locals in module sources and backend configuration, evaluated statically. A better error message for for_each over a sensitive value of an unsuitable type.

OpenTofu 1.9

for_each in aliased provider blocks, and the -exclude planning option.

Terraform 1.15 (2026-04-29)

Variables and locals are allowed in module source and version (with const variables).

OpenTofu 1.11

The enabled meta-argument (inside lifecycle) as an alternative to count for the zero-or-one case; modules that contain local provider configurations now also reject enabled, as they already did for count, for_each and depends_on.

Terraform 1.17 (2026-10-21)

The experimental "deferred actions" feature (alpha builds only, terraform plan -allow-deferral) allows unknown count and for_each in module, resource and data blocks. Stable releases still require known values.

Where the books are outdated or wrong

The reference books for this section present count and for_each mostly as tutorials, and a few points need updating.

  • They teach count = length(var.list) with count.index as the general way to repeat resources, and mention in a sentence that removing an element reshuffles the rest. In practice that sentence deserves a heading: it is the deciding argument for for_each keyed by a stable name whenever instances have identities.

  • They describe the limitation as "count and for_each cannot depend on computed values" without the key versus value distinction. Since the keys alone have to be known, a map with static keys and apply-time values is the standard way around it, not a two-step -target apply.

  • Older material predates for_each on modules (Terraform 0.13) and treats module repetition as impossible, and it predates moved blocks (1.1), so a switch from count to for_each is shown with manual terraform state mv commands. A moved block gives the same result and is reviewable.

  • One book states that Terraform has no built-in mechanism for dependencies between modules. That is wrong: since Terraform 0.13 a module block accepts depends_on, which makes every object in the module wait for the listed resources or modules (see the depends_on section above). Implicit dependencies through module outputs remain the better choice when a value is passed anyway.

  • terraform taint, often used together with count examples to force recreation of one instance, is deprecated in favour of terraform apply -replace=ADDRESS; the address of an instance is exactly the one printed by terraform state list.

On any discrepancy follow the official documentation for the version you run.

Pitfalls

  • Using count = length(var.names) and removing an element from the middle: the plan shows in-place changes and replacements for every later instance and a destruction at the end. Use for_each over a map or a set.

  • Using a key that comes from an editable attribute (a display name, a computed id). Changing the attribute changes the key, which destroys and recreates the instance. Use a stable identifier and keep the editable data in the value.

  • Passing a list to for_each without toset(), or a map of lists where a map of objects was meant.

  • Building keys from resource attributes that are known only after apply, and "fixing" the error with a permanent -target workflow instead of restructuring the keys.

  • Sprinkling depends_on on every resource "to be safe". It makes plans conservative and hides the real relationships; use it only for hidden dependencies, and comment each one.

  • A depends_on on a data source that causes its read to be deferred until apply, which makes its results unknown for count or for_each.

  • Using depends_on on a module that contains many resources: every resource waits for the dependency, and any change can cascade. Pass a value from the dependency as an input instead.

  • Putting provider blocks inside child modules and then trying to use count, for_each or depends_on on the module call, or removing the module.

  • Forgetting that docker_container.catalog_api[0] is not valid for a block with for_each, and that docker_container.service["web"] is not valid for a block with count.

  • Forgetting to quote an instance address in a shell, so ["web"] loses its quotes and the target is not found.

  • Referencing a conditional resource (count = 0) from another resource without handling the empty list.