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 |
|---|---|
|
Create several instances from one block. Mutually exclusive. See Meta-arguments: count, for_each and depends_on. |
|
Declare a dependency Terraform cannot infer from references, so ordering is guaranteed. |
|
Select a non-default (aliased) provider configuration, for example another region or account. See Providers. |
|
Customize how Terraform plans changes: |
|
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 ( |
Computed (read-only) attribute |
You cannot set it; the provider fills it in after the object exists. Examples: the |
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.
Source: Terraform Docs — terraform plan.
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) |
|
An argument that cannot be changed in place was modified, or replacement was requested. Marked
|
Destroy |
|
The instance is in the state but no longer in the configuration, or |
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.
Replacement can also be requested rather than inferred. The options, each covered in the page named, are:
-
terraform apply -replace=ADDRESS(andplan -replace), the supported successor of the oldterraform taintcommand, which marks a single instance for replacement during one run. See The Dependency Graph and the Plan. -
The
replace_triggered_bylifecycle setting, which replaces a resource whenever another resource or one of its attributes changes. -
A change in
triggers_replaceof aterraform_dataresource or inkeepersof 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.
Source: Terraform Docs — Resource behavior.
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
}
}
Source: Terraform Docs — data block reference, Docker provider — docker_registry_image and Docker provider — docker_network.
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 (
preconditionorpostcondition) 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 |
|---|---|
|
Any value, stored in the instance state. Terraform shows the |
|
Any value, stored in state; the resource is replaced when it changes. Replacing is what re-runs a provisioner or
cascades through |
|
The computed copy of |
|
A string unique to the instance. |
|
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 |
|---|---|
|
Accepts any type, including ephemeral values. The value is copied to |
|
When |
|
When set, a change to |
|
When |
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 |
|---|---|---|
|
3.9.1 |
Managed randomness: |
|
0.14.2 |
Time-based values and delays: |
|
4.4.2 |
Keys and certificates: |
|
3.3.2 |
A single no-op |
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}"
}
Source: random provider — documentation index, random_pet and random_password.
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
}
Source: time_static, time_rotating and time_sleep.
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",
]
}
Source: tls_private_key and tls_self_signed_cert.
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 |
|
Stop managing without destroying |
A |
The same block; OpenTofu’s |
|
Available since 1.16. |
Not found. The OpenTofu documentation page for |
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
|
Provider documentation |
|
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
storeblock ofterraform_data, which holds ephemeral and sensitive values across plan and apply;importblocks inside modules; and planned private data for providers, so provider-specific state can be preserved between plan and apply. A fix forcreate_before_destroyordering in some combinations of changes is in the 1.16 patch releases. -
Terraform 1.17 (2026-10-21): the new
-minimal-refreshplanning 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_providerinterraform testnow 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:
movedblocks can migratenull_resourcetoterraform_data. Terraform 1.4:terraform_datawas introduced as the replacement fornull_resource(thenullprovider documentation states 1.4 as the point from which to prefer it). -
Providers:
random_passwordandrandom_stringdeprecatenumberin favor ofnumeric; 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 providerfor_eachare 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
preconditionandpostconditionconditions and meaningful error messages instead of assuming a result. -
Everything downstream is unknown. A
depends_onon 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 tofor_each. -
Silent replacements. Changing one argument can destroy and recreate a database. Read the plan for
# forces replacementand protect important resources withprevent_destroyand a reviewed plan file. -
Secrets in state.
random_password,tls_private_keyand thestoreblock 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; usetime_staticorplantimestamp(). Computed server-side defaults are handled by the provider’s optional-and-computed attributes, or, if not, byignore_changes. -
Random values that change unexpectedly. A
keepersentry 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_resourceor provisioners first. Prefer a native resource, a provider action orterraform_datawith atriggers_replacethat names the real cause.