Resources and Data Sources

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.

Resources and data sources are the two building blocks that make a Terraform configuration do something. A resource describes an infrastructure object that Terraform creates, updates and destroys; a data source describes an object that Terraform only reads. Everything else in the language — variables, locals, expressions, modules — exists to feed values into these two kinds of block. This page describes them as documented for Terraform 1.17 (released 2026-10-21; the previous line is 1.16.x) and OpenTofu 1.13, and uses the kreuzwerker/docker provider 4.6, the hashicorp/aws provider 6.x and the utility providers random 3.9, time 0.14, tls 4.4 and null 3.3 in its examples. It is the fourth step of the reading path: it assumes HCL Syntax and Configuration Files and Providers, and it is followed by Variables, Outputs and Locals, Meta-arguments: count, for_each and depends_on and Lifecycle, Validation and Checks, which go deeper into the settings introduced here. How Terraform orders these operations is the subject of The Dependency Graph and the Plan.

The running example is the local slice of the Bookshelf scenario (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers bucket, network, DNS, secrets, IAM): containers on a Docker network, a generated database password and a development TLS certificate, all runnable on a laptop. The container concepts themselves are covered in Getting Started with Docker and Docker Networking.

What a resource is

A resource is one infrastructure object that Terraform manages for the whole of its life: a virtual network, a compute instance, a DNS record, a Docker container, an IAM role. A resource block does not run anything. It declares the desired state of one object (or of several, with count or for_each), and Terraform works out how to move reality from whatever it finds to what the block says.

The work is split between two parties. Terraform core reads the configuration, builds the dependency graph, compares the desired state with the recorded state, produces the plan and decides the order of operations. The provider (Providers) owns everything specific to a technology: it defines which resource types exist, which arguments each type accepts and which attributes it exposes (the schema), and it implements the create, read, update and delete calls against the real API. Core never knows what a docker_container is; it only knows the provider’s schema and the results the provider returns.

The resource block is one of the top-level block types listed in HCL Syntax and Configuration Files. It takes two labels, the resource type and a local name:

resource "<TYPE>" "<NAME>" {
  # arguments defined by the provider's schema for <TYPE>
  # meta-arguments defined by Terraform itself
}

The type is chosen from the provider’s documentation and by convention starts with the provider’s local name (docker_container, aws_db_instance, random_password). The name is yours and has to be unique among resources of the same type in the module. The pair forms the resource address, docker_container.catalog_api, which is how the rest of the configuration, the plan output and every state and CLI command refer to the object. With count or for_each the address gains an instance key (docker_container.catalog_api[0], docker_container.catalog_api["eu"]). Inside a child module the address is prefixed with the module path (module.network.docker_network.bookshelf).

Besides provider-specific arguments, a resource block accepts meta-arguments that Terraform itself defines and that work on any resource type:

Meta-argument Purpose

count, for_each

Create several instances from one block. Mutually exclusive. See Meta-arguments: count, for_each and depends_on.

depends_on

Declare a dependency Terraform cannot infer from references, so ordering is guaranteed.

provider

Select a non-default (aliased) provider configuration, for example another region or account. See Providers.

lifecycle

Customize how Terraform plans changes: create_before_destroy, prevent_destroy, ignore_changes, replace_triggered_by, precondition, postcondition and action_trigger. See Lifecycle, Validation and Checks. The values must be literals, because Terraform reads them before it evaluates other expressions.

connection, provisioner

Run local or remote commands after creation or before destruction. Last-resort tools; see Provisioners, Actions and Post-Apply Operations.

The meta-argument list above follows the 1.17 resource block reference. OpenTofu adds enabled inside lifecycle (see Terraform and OpenTofu).

terraform {
  required_version = ">= 1.16"

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

provider "docker" {}

# Two resources, one dependency: the container reads an attribute of the
# image, so Terraform creates the image first (an implicit dependency).
resource "docker_image" "catalog_api" {
  name         = "bookshelf/catalog-api:1.4.0"
  keep_locally = true
}

resource "docker_container" "catalog_api" {
  name  = "catalog-api"
  image = docker_image.catalog_api.image_id

  ports {
    internal = 8080
    external = 18080
  }
}

Arguments, attributes and computed values

Terraform’s documentation draws a careful line between two words that the HCL specification treats as one. An argument is something you write inside the block to configure the object (image = ...). An attribute is something you read from the object through a reference (docker_container.catalog_api.id). The two sets overlap but are not the same, and the provider’s schema decides which is which. Every attribute of a resource type falls into one of four categories:

Category (schema) What it means in practice

Required argument

You must set it. Omitting it is a validation error before any plan is made.

Optional argument

You may set it. When you do not, either the provider supplies a default or the attribute stays unset (null).

Computed (read-only) attribute

You cannot set it; the provider fills it in after the object exists. Examples: the id of almost every resource, the arn of an AWS object, the generated result of random_password. The provider documentation lists them under "Read-Only" or "Attributes Reference".

Optional and computed

You may set it; if you do not, the provider chooses a value and records it. Typical for server-side defaults such as a default subnet, an engine version or a port. Because the value is chosen by the provider, leaving it out is not a change when the remote value later differs.

The consequence that matters most is known after apply. A computed attribute does not exist until the provider has created the object, so at plan time it is unknown. The plan shows it as (known after apply), and any expression that depends on it is unknown too. Unknown values are fine as inputs to other resources, which is how Terraform chains creations together, but they cannot be used where Terraform needs a concrete value to plan, such as the key set of a for_each (see Meta-arguments: count, for_each and depends_on).

$ terraform plan

Terraform will perform the following actions:

  # docker_container.catalog_api will be created
  + resource "docker_container" "catalog_api" {
      + hostname = (known after apply)
      + id       = (known after apply)
      + image    = (known after apply)
      + name     = "catalog-api"
        # (output omitted)
    }

Plan: 2 to add, 0 to change, 0 to destroy.

References between resources follow a fixed grammar. <TYPE>.<NAME>.<ATTRIBUTE> reads an attribute; <TYPE>.<NAME>[0] or <TYPE>.<NAME>["key"] selects an instance; a bare <TYPE>.<NAME> is the whole object (or a collection of them). A reference is also a dependency: Terraform builds its graph from references, so the container above is created after the image without any further declaration. Inside connection and provisioner blocks the special object self stands for the resource’s own attributes, because naming the resource there would be a cycle.

Two practical rules follow from the argument/attribute split. First, read the provider documentation’s schema, not a blog post, to know which names are arguments and which are attributes; a mistake is caught at terraform validate. Second, treat the whole resource as a record in the state file: Terraform stores the arguments and the attributes the provider returned. That is why anything a resource exposes, including secrets such as a generated password or a private key, ends up in the state. See State and Sensitive Data, Ephemeral Values and Write-Only Arguments.

Create, update, replace and destroy

Every plan is a comparison of three things: the configuration (the desired state), the state file (what Terraform believes exists) and, after the refresh step, what the provider reports actually exists. For each resource instance the result is one of four actions, and the plan prefixes each with a symbol:

Action Symbol When Terraform chooses it

Create

+

The instance is in the configuration but not in the state (or the real object is gone).

Update in place

~

Arguments changed and the provider can apply the change to the existing object without recreating it.

Replace (destroy and create)

-/+ (or +/-)

An argument that cannot be changed in place was modified, or replacement was requested. Marked # forces replacement on the offending argument. +/- appears when create_before_destroy flips the order.

Destroy

-

The instance is in the state but no longer in the configuration, or terraform destroy was run.

Read (data source)

<=

A data source is read as part of the operation (see Data sources).

The plan symbols are the CLI’s convention rather than a documented specification, so rely on the textual summary line (Plan: 1 to add, 2 to change, 1 to destroy.) and the # ... will be ... comments when reading output.

Whether a change is an update or a replacement is not decided by Terraform core on its own. It is a property of each argument in the provider’s schema: the provider marks an argument as one that forces a new resource (the Plugin SDK called this ForceNew), and core then plans a replace if that argument changes. Renaming a docker_container, changing the engine of a database or the CIDR of a network typically replaces; changing a tag or a description usually updates in place. The provider documentation does not always spell this out, so the plan is the authority: read it before applying, and look for # forces replacement.

flowchart TD A[Compare configuration, state and refreshed reality] --> B{Instance in configuration?} B -- no, but in state --> D[Destroy] B -- yes --> C{Instance in state?} C -- no --> E[Create] C -- yes --> F{Arguments differ?} F -- no --> G[No-op] F -- yes --> H{Replacement forced or requested?} H -- no --> I[Update in place] H -- yes --> J{create_before_destroy?} J -- no --> K[Destroy old object, then create new] J -- yes --> L[Create new object, then destroy old] E --> M[(State updated)] I --> M K --> M L --> M D --> M

Replacement can also be requested rather than inferred. The options, each covered in the page named, are:

  • terraform apply -replace=ADDRESS (and plan -replace), the supported successor of the old terraform taint command, which marks a single instance for replacement during one run. See The Dependency Graph and the Plan.

  • The replace_triggered_by lifecycle setting, which replaces a resource whenever another resource or one of its attributes changes.

  • A change in triggers_replace of a terraform_data resource or in keepers of a utility resource, described below.

Destroy happens when an instance disappears from the configuration, when terraform destroy is run (which is apply -destroy) or when -destroy is passed to plan. Terraform destroys in reverse dependency order: the container before the image it uses, the image before the network. If a resource must never be deleted by accident, set prevent_destroy = true in its lifecycle block and Terraform will error out of any plan that would destroy it; if you want to stop managing an object without destroying it, use a removed block (see Refactoring with moved and removed). To bring an existing object under management, see Importing and Querying Existing Infrastructure.

The high-level resource behavior is summarized by HashiCorp in four sentences: resources in the configuration that do not exist are created, resources in the state but not in the configuration are destroyed, resources whose arguments changed are updated in place when possible, and resources whose changed arguments cannot be updated because of remote API limitations are destroyed and created again. After a successful apply the state is updated so that configuration, reality and state agree. If the apply fails half way, Terraform records what did get created, which is why a second apply continues from where the first one stopped rather than starting over.

A replacement in practice

$ terraform apply

  # docker_container.catalog_api must be replaced
-/+ resource "docker_container" "catalog_api" {
      ~ id    = "f3b1..." -> (known after apply)
      ~ name  = "catalog-api" -> "catalog-api-v2" # forces replacement
        # (output omitted)
    }

Plan: 1 to add, 0 to change, 1 to destroy.

The output above is abbreviated and illustrative; the exact set of attributes depends on the provider version.

Operation timeouts

Some resource types accept a timeouts setting that bounds how long Terraform waits for the provider to finish a create, update or delete call. It is not a Terraform core meta-argument: the 1.17 resource block reference lists count, for_each, depends_on, provider and lifecycle and does not document timeouts. A timeout exists only when the provider’s schema for that resource type defines it, and each provider documents the supported operations and their defaults on the resource’s page (for example create, update and delete, with defaults such as 40 minutes to create an aws_db_instance). Most resource types do not offer it, and the OpenTofu documentation says so explicitly.

Providers expose timeouts in one of two syntaxes, and the resource documentation says which. Older providers (and those still on the Plugin SDK) use a nested block; providers written with the Plugin Framework are encouraged to use nested attributes, so the practitioner writes an equals sign:

# Block form, used by many existing resources.
resource "aws_db_instance" "catalog" {
  # ... arguments omitted ...

  timeouts {
    create = "60m"
    update = "90m"
    delete = "30m"
  }
}

# Attribute form, used by newer provider code.
# resource "example_thing" "x" {
#   timeouts = {
#     create = "60m"
#   }
# }

Values are Go-style duration strings such as 60m or 2h. Raising a timeout is the right answer to a slow but successful operation (a database, a managed cluster, a CDN distribution); lowering it makes Terraform fail fast. A timeout that expires does not undo anything: the provider reports an error, Terraform records whatever state it can and the next plan shows the difference, so a tainted or half-created object may need -replace or an import.

Do not confuse timeouts with two unrelated settings that carry similar names: the timeout argument of a connection block (how long to wait for SSH or WinRM, default 5m) and provider-specific tunables such as the Docker provider’s wait_timeout (seconds to wait for a container to become healthy) or stop_timeout. The Docker docker_container resource does accept a timeouts block with create, update and delete.

Data sources

A data source reads information from a provider and exposes it as attributes, without creating, changing or destroying anything. It answers questions Terraform cannot answer from its own state: which AMI is the newest, which network already exists, what is the digest of an image tag, what are the outputs of another configuration. A data source is declared with a data block, two labels (type and name) and a reference form that starts with data.:

data "<TYPE>" "<NAME>" {
  # arguments that select what to read (provider-defined)
  # meta-arguments: count, for_each, depends_on, provider, lifecycle
}

# Referenced as: data.<TYPE>.<NAME>.<ATTRIBUTE>

The data block accepts count, for_each, depends_on, provider and lifecycle; inside lifecycle only precondition and postcondition are available, which is how a configuration asserts "the image I looked up really has the label I expect". The terraform_remote_state data source is built in and is covered in Backends and Remote State.

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

# A network that another stack (for example the platform team's) created.
data "docker_network" "bookshelf" {
  name = "bookshelf"
}

# The current digest of an image tag, read from the registry.
data "docker_registry_image" "web" {
  name = "bookshelf/web:1.4.0"
}

resource "docker_image" "web" {
  name          = data.docker_registry_image.web.name
  pull_triggers = [data.docker_registry_image.web.sha256_digest]
}

resource "docker_container" "web" {
  name  = "web"
  image = docker_image.web.image_id

  networks_advanced {
    name = data.docker_network.bookshelf.name
  }
}

When a data source is read

Terraform tries to read data sources during planning, so that the plan can show real values. Concretely, if every argument of the data block is already known, the read happens in the refresh phase that runs before the plan, and the attributes appear in the plan with their actual values. The read is deferred to apply time when it cannot be done safely earlier:

  • An argument refers to a value that is not known until apply, for example an attribute of a resource that is being created or replaced in the same run.

  • The data source depends directly on a managed resource that has planned changes.

  • The data source has custom conditions (precondition or postcondition) and depends, directly or indirectly, on a managed resource with planned changes.

A deferred data source shows up in the plan as # data.<TYPE>.<NAME> will be read during apply, and everything derived from its attributes is (known after apply). That has the same consequences as any other unknown value: it cannot feed a for_each or count, and resources that need a concrete value at plan time will fail. Because a data source read is a normal operation, a data source whose lookup fails (nothing found, ambiguous result, permission denied) stops the plan with an error rather than producing an empty result.

Depending on a data source

A reference to a data source’s attribute creates an ordinary implicit dependency: Terraform reads the data source first and then plans the resources that use it. An explicit depends_on on a data block delays the read until the listed dependencies have been applied, which forces the read to apply time and therefore makes everything that uses it unknown during the plan. Use it only when the lookup genuinely depends on a side effect Terraform cannot see (for example "read this policy after the role has been attached"). Since Terraform 0.13 depends_on on a data source behaves this way; the documentation still warns about the older behavior only for 0.12 and earlier. A data source that references a managed resource in its own arguments behaves as if that resource were in its depends_on; if you want to avoid that, route the value through a locals entry so the dependency is not direct, as the OpenTofu documentation points out.

Local-only data sources

A few data sources run entirely inside Terraform and never contact an API: local_file, the aws_iam_policy_document renderer and the legacy template_file data source. Their results exist only during the operation and are recomputed on every plan. They are convenient for assembling documents (a JSON IAM policy, a configuration file). The legacy template_file data source, which Hafner’s book presents as legacy, is replaced by the built-in templatefile() and templatestring() functions, covered in Functions.

Data sources and check blocks

A check block can contain a scoped data source whose failure produces a warning rather than an error, which is the tool for "is the application healthy?" assertions after apply; see Lifecycle, Validation and Checks.

The terraform_data resource

terraform_data is a managed resource that is built into Terraform: it belongs to the built-in provider terraform.io/builtin/terraform, needs no required_providers entry and no configuration, and "implements the standard resource lifecycle but takes no other actions". It exists for two jobs: to store a value that follows a managed resource lifecycle, and to give provisioners (or actions) a place to run when no real resource fits. It is the supported replacement for the null_resource of the null provider; the null provider’s own documentation tells you to use terraform_data on Terraform 1.4 and later, and says Terraform 1.9 and later support a moved block from null_resource to terraform_data.

Name Meaning

input (argument, optional)

Any value, stored in the instance state. Terraform shows the output attribute after apply.

triggers_replace (argument, optional)

Any value, stored in state; the resource is replaced when it changes. Replacing is what re-runs a provisioner or cascades through replace_triggered_by.

output (attribute)

The computed copy of input. When input is unknown at plan time, output is unknown too, with the same type.

id (attribute)

A string unique to the instance.

store (nested block, optional, 1.16)

Holds a value across plan and apply. See below.

The most common use is to turn a plain value into something replace_triggered_by can observe. That setting accepts only resource references, so a variable or local cannot trigger a replacement directly; routing it through terraform_data fixes that:

variable "revision" {
  type    = string
  default = "2026-10-21"
}

resource "terraform_data" "catalog_api_revision" {
  input = var.revision
}

resource "docker_container" "catalog_api" {
  name  = "catalog-api"
  image = "bookshelf/catalog-api:1.4.0"

  lifecycle {
    # Replace the container whenever the revision label changes.
    replace_triggered_by = [terraform_data.catalog_api_revision]
  }
}

The second use is the "anchor" for one-off operations, with triggers_replace listing the values that should cause a re-run. A provisioner is the documented last resort (see Provisioners, Actions and Post-Apply Operations):

resource "terraform_data" "seed_catalog" {
  triggers_replace = [docker_container.catalog_api.id]

  provisioner "local-exec" {
    command = "./scripts/seed-catalog.sh"
  }
}

The store block (Terraform 1.16)

Before 1.16 there was no way to keep an ephemeral or sensitive value alive between the plan and the apply of one run. Terraform 1.16 (2026-08-26) added a store block to terraform_data for exactly that: "the new store block can hold ephemeral and sensitive values across plan and apply", according to the release notes. The block has four arguments:

Argument Meaning

input (write-only)

Accepts any type, including ephemeral values. The value is copied to output, or to sensitive_output when sensitive is set, and is stored in the state.

sensitive

When true, the value is exposed as the sensitive_output attribute instead of output. The two attributes are mutually exclusive.

version

When set, a change to input is not reflected in the stored output unless version also changes, which makes rotation an explicit decision.

replace

When true, a change in the stored output replaces the resource instead of updating it in place.

A typical pattern generates a password with an ephemeral resource, stores it in terraform_data so other resources can read it during the same run, and feeds it to a write-only argument elsewhere. The value is persisted in the state by store, so it is not a way to keep a secret out of state; for that, consume the ephemeral value directly in a write-only argument. See Sensitive Data, Ephemeral Values and Write-Only Arguments.

terraform {
  required_version = ">= 1.16"

  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.9"
    }
  }
}

ephemeral "random_password" "reviews_db" {
  length = 32
}

resource "terraform_data" "reviews_db_password" {
  store {
    input     = ephemeral.random_password.reviews_db.result
    sensitive = true
    version   = 1 # bump to rotate
  }
}

output "reviews_db_password" {
  value     = terraform_data.reviews_db_password.store.sensitive_output
  sensitive = true
}

Utility providers

Terraform’s registry contains several logical providers that do not talk to any service. Their resources compute something inside Terraform and then keep the result in the state until a trigger changes. They are the building blocks of the "state-only resources" pattern: values that must be generated once and then stay stable across runs. The table lists the current versions at the time of writing (registry, 2026-10-23):

Provider Version Purpose

hashicorp/random

3.9.1

Managed randomness: random_id, random_integer, random_password, random_pet, random_shuffle, random_string, random_uuid, random_bytes.

hashicorp/time

0.14.2

Time-based values and delays: time_static, time_rotating, time_offset, time_sleep.

hashicorp/tls

4.4.2

Keys and certificates: tls_private_key, tls_self_signed_cert, tls_cert_request, tls_locally_signed_cert.

hashicorp/null

3.3.2

A single no-op null_resource, superseded by terraform_data since Terraform 1.4.

Two cautions apply to all of them. First, because the generated values live in the state, the random and tls resources are only as secret as the state itself; the tls_private_key documentation states plainly that the key is stored unencrypted in state and that the resource is intended for throwaway development environments, and the random provider says its results are not sufficiently random for cryptographic use unless a resource says otherwise (random_password and random_bytes do use a cryptographic generator). Second, use them sparingly: the random provider itself advises keeping randomness in a configuration to a minimum, and the null provider warns that its constructs make configurations harder to understand.

random: managed randomness and keepers

A random_* resource generates its value at creation and then returns the same value on every later run, so the configuration still converges. To get a new value, make the resource recreate: change a keepers map (arbitrary key/value pairs; when any value changes, the resource is replaced) or use -replace.

terraform {
  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.9"
    }
  }
}

variable "environment" {
  type    = string
  default = "dev"
}

# A unique, readable suffix that changes only when the environment does.
resource "random_pet" "bookshelf" {
  length = 2
  keepers = {
    environment = var.environment
  }
}

# A generated, sensitive database password for reviews-api.
resource "random_password" "reviews_db" {
  length  = 24
  special = true
}

output "bucket_name" {
  value = "bookshelf-covers-${random_pet.bookshelf.id}"
}

random_password marks its result as sensitive so it is not printed, but it is still written to the state. The provider documentation therefore recommends, when the target resource has a write-only attribute for the password (introduced in Terraform 1.11), the ephemeral variant of random_password instead, so the value is kept out of plan and state. The number argument of random_password and random_string is deprecated in favor of numeric.

time: stable timestamps, rotation and delays

The time provider exists because timestamp() returns a new value on every plan, which causes a perpetual diff when it is used in a resource argument. time_static records the time once and keeps it; time_rotating additionally proposes recreation after rotation_days, rotation_hours and similar, which is a way to schedule key or certificate rotation (the rotation only happens when Terraform runs, so the timestamp drifts from the real moment); time_offset computes a time relative to a base; and time_sleep delays creation and/or destruction by a duration, for the occasional eventually consistent API. The provider’s own documentation calls time_sleep "a workaround" for issues that should be handled in the downstream provider, so prefer fixing or reporting the cause.

terraform {
  required_providers {
    time = {
      source  = "hashicorp/time"
      version = "~> 0.14"
    }
  }
}

# Record when the image tag last changed instead of calling timestamp().
resource "time_static" "catalog_api_deployed" {
  triggers = {
    image = "bookshelf/catalog-api:1.4.0"
  }
}

# Propose a new rotation timestamp every 30 days.
resource "time_rotating" "reviews_db_password" {
  rotation_days = 30
}

# Give a freshly created IAM role time to propagate before it is used.
resource "time_sleep" "role_propagation" {
  create_duration = "30s"

  triggers = {
    role = "bookshelf-reviews-api"
  }
}

output "deployed_at" {
  value = time_static.catalog_api_deployed.rfc3339
}

time_rotating is often combined with replace_triggered_by to rotate a credential: when the rotation timestamp passes, the next plan replaces the time_rotating resource, and everything that lists it in replace_triggered_by follows.

tls: development keys and certificates

tls_private_key generates an RSA, ECDSA or ED25519 key; tls_self_signed_cert turns it into a self-signed certificate; tls_cert_request and tls_locally_signed_cert model a small internal certificate authority. This is the fastest route to HTTPS for a local Bookshelf web container and for tests. It is not a production PKI: the private key is stored unencrypted in the state, and self-signed certificates are not trusted by browsers. A certificate’s early_renewal_hours makes the resource consider the certificate expired that many hours before its real expiry, which produces a replacement plan in good time, and ready_for_renewal reports whether that point has been reached.

terraform {
  required_providers {
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.4"
    }
  }
}

resource "tls_private_key" "web" {
  algorithm   = "ECDSA"
  ecdsa_curve = "P256"
}

resource "tls_self_signed_cert" "web" {
  private_key_pem = tls_private_key.web.private_key_pem

  subject {
    common_name  = "web.bookshelf.test"
    organization = "Bookshelf (development)"
  }

  validity_period_hours = 720
  early_renewal_hours   = 48

  allowed_uses = [
    "key_encipherment",
    "digital_signature",
    "server_auth",
  ]
}

null: a historical no-op

null_resource has a triggers map and a random id, and nothing else. It was the standard anchor for provisioners for years and still appears in older configurations and in both books. New code should use terraform_data instead, which needs no provider download and has input/output as well; existing code can be migrated with a moved block (Terraform 1.9 and later), as shown in Refactoring with moved and removed.

moved {
  from = null_resource.seed_catalog
  to   = terraform_data.seed_catalog
}

resource "terraform_data" "seed_catalog" {
  triggers_replace = [docker_container.catalog_api.id]
}

Terraform and OpenTofu

The resource and data blocks, the plan symbols, the replace and destroy semantics and the deferred read of data sources are the same in OpenTofu 1.13 as in Terraform. The differences are at the edges:

Topic Terraform 1.17 OpenTofu 1.13

Conditional single resource

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

lifecycle { enabled = var.create } (since 1.11). The resource, if disabled, evaluates to null rather than an empty list, so use try() or a null check when reading its attributes. It cannot be combined with count or for_each, and the value must be known at plan time, not sensitive or ephemeral. The OpenTofu data source documentation also describes enabled for data blocks.

Stop managing without destroying

A removed block with lifecycle { destroy = false }.

The same block; OpenTofu’s removed block can additionally carry destroy-time provisioners when lifecycle { destroy = true }.

terraform_data store block

Available since 1.16.

Not found. The OpenTofu documentation page for terraform_data could not be retrieved, and the schema in the OpenTofu source (main branch) lists only input, output, triggers_replace and id. Treat the store block as Terraform-only until the OpenTofu documentation says otherwise.

Ephemeral resources and write-only arguments

Since 1.10 and 1.11.

Supported in current OpenTofu releases; the OpenTofu resource documentation points to write-only attributes such as secret_string_wo as the alternative to storing a secret.

Provider documentation

registry.terraform.io.

The OpenTofu Registry, which mirrors the same community providers; the utility providers used here behave the same.

The utility providers are ordinary providers, so OpenTofu installs the same hashicorp/random, hashicorp/time, hashicorp/tls and hashicorp/null packages (from its own registry). See OpenTofu for the full comparison.

What changed recently

  • Terraform 1.16 (2026-08-26): the store block of terraform_data, which holds ephemeral and sensitive values across plan and apply; import blocks inside modules; and planned private data for providers, so provider-specific state can be preserved between plan and apply. A fix for create_before_destroy ordering in some combinations of changes is in the 1.16 patch releases.

  • Terraform 1.17 (2026-10-21): the new -minimal-refresh planning option, which refreshes only resources that have proposed changes (useful on large states where refreshing every resource dominates the plan time), and variables and locals in provider requirements. mock_provider in terraform test now supports ephemeral resources.

  • Terraform 1.11: write-only arguments (*_wo), which let a resource accept a secret without storing it in plan or state. Terraform 1.10: ephemeral resources.

  • Terraform 1.9: moved blocks can migrate null_resource to terraform_data. Terraform 1.4: terraform_data was introduced as the replacement for null_resource (the null provider documentation states 1.4 as the point from which to prefer it).

  • Providers: random_password and random_string deprecate number in favor of numeric; the AWS provider is in the 6.x line and the Docker provider in its 4.x line, so check the changelog of the major you pin.

  • OpenTofu 1.11: lifecycle { enabled }. OpenTofu’s own plan and state encryption and provider for_each are covered in OpenTofu.

The books are older than all of the above. Both treat null_resource and a timeouts block as everyday tools and predate the store block. Hafner (2025, Terraform about 1.9) presents template_file as legacy, correctly, and notes state-only resources (random, time, null, terraform_data) as a pattern to use sparingly, which matches the providers' own advice. Wang (2024) generates secret_data through resources and keeps it in state, and uses timestamp() inside resource names, which produces a perpetual diff; the current approaches are write-only arguments and ephemeral resources for secrets, and plantimestamp(), time_static or ignore_changes for the timestamp. Neither book covers terraform_data store, -minimal-refresh or lifecycle { enabled }.

Common pitfalls

  • A data source that returns nothing. A lookup that matches no object is an error, not an empty value, so build precondition and postcondition conditions and meaningful error messages instead of assuming a result.

  • Everything downstream is unknown. A depends_on on a data source, or a data source reading a resource that is changing, moves the read to apply and makes every derived value (known after apply). Avoid feeding such values to for_each.

  • Silent replacements. Changing one argument can destroy and recreate a database. Read the plan for # forces replacement and protect important resources with prevent_destroy and a reviewed plan file.

  • Secrets in state. random_password, tls_private_key and the store block all persist values in the state; protect and encrypt the state and prefer ephemeral and write-only patterns.

  • Perpetual diffs. Functions such as timestamp() change every run; use time_static or plantimestamp(). Computed server-side defaults are handled by the provider’s optional-and-computed attributes, or, if not, by ignore_changes.

  • Random values that change unexpectedly. A keepers entry that is itself unknown, or that changes when a tag is edited, will recreate the random resource and everything that consumes it. Choose keepers that change only when you want new randomness.

  • Reaching for null_resource or provisioners first. Prefer a native resource, a provider action or terraform_data with a triggers_replace that names the real cause.