Domain 4 of 8 · Chapter 1 of 8

Resource and data blocks

Managed resources and data sources

Two block types carry almost all of a Terraform configuration, and on the page they look nearly alike:

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
}

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]
}

The difference is ownership. A resource block declares a managed resource, a real object Terraform creates, updates, and destroys[1] so infrastructure matches your configuration. A data block declares a data source, a read-only lookup that fetches data from the provider without creating or modifying anything[2]. In the example Terraform builds and owns the aws_instance, but only reads the aws_ami that someone else published.

Two labels form the address

Both blocks open with a keyword and two labels, a type and a local name. In resource "aws_instance" "web", the type aws_instance comes from the provider and fixes what the object is, while the local name web is yours to choose. Together they form the block's address, aws_instance.web, which Terraform uses to track the object in state[1] and which must be unique within the module. The local name is only an internal handle: it does not need to be globally unique, and it never has to match an id the provider assigns on create.

The data. prefix is the read-only tell

You wire blocks together by referencing their exported attributes. A managed resource's attribute reads as aws_instance.web.id; a data source's has the same shape with a leading data. marker, data.aws_ami.ubuntu.id. That data. prefix is the single syntactic signal that a reference points at a read-only lookup rather than a managed object. Drop it and Terraform looks for a managed resource of that name instead, so the prefix, not the block's position in the file, is what tells configuration and reader alike which kind of block owns a value.

resource blockaws_instance.webcreates, updates, destroysManaged objectTerraform owns itdata blockdata.aws_ami.ubuntureadsExisting objectread-only, never written
Both blocks share the type.name address; the resource block owns the object's lifecycle while the data block only reads it.

Meta-arguments: count, for_each, and the rest

Every resource and data block accepts a fixed set of meta-arguments that Terraform interprets itself, independent of the provider. Learn them once and they behave the same on any resource type.

Meta-argument What it does
depends_on Forces an explicit ordering when Terraform cannot infer it from references
count Creates a fixed number of instances, indexed from 0
for_each Creates one instance per map entry or set member, keyed by that element
provider Selects a non-default (aliased) provider configuration
lifecycle Tunes create, replace, and destroy behavior (create_before_destroy, prevent_destroy, ignore_changes)

The reference lists exactly these five for a resource block[1]; a data block accepts the same set, plus precondition and postcondition checks inside its lifecycle.

count indexes; for_each keys

count and for_each both stamp out multiple instances from one block, but they identify those instances differently, and you cannot use both in the same block[3].

  • count takes a whole number[3] and creates that many instances. Inside the block count.index is the position starting at 0, and you address an instance by number: aws_instance.web[0].
  • for_each takes a map or a set of strings[4] and creates one instance per element. Inside the block each.key and each.value describe the current element, and you address an instance by its key: aws_instance.web["blue"].

count.index is available only under count, and each.key / each.value only under for_each; reaching for the wrong one is a common configuration error.

Why for_each survives edits that count churns

Prefer for_each when instances have their own identity. Because for_each tracks instances by key, deleting the middle entry of a three-item map removes exactly that one instance. Under count, deleting the middle of a three-item list shifts every later instance down one index, and since state is keyed by that index, Terraform plans to destroy and recreate each shifted instance[3]. Switching an existing resource from count to for_each re-addresses every instance the same way, so pair the switch with a moved block to keep the objects instead of replacing them.

How and when data sources are read

A data source's value is only as fresh as its last read, so knowing when Terraform reads it explains most data-source surprises. The rule has two branches.

Terraform reads a data source during the refresh phase[2], the read-back step at the start of plan and apply where Terraform checks real objects against its records, when all of the data source's arguments are already known, for example a hard-coded owner id or a plain variable. The result is available during plan, so the plan can show what the lookup returned.

Terraform defers the read to the apply phase[2] when at least one argument refers to a value it cannot predict during planning, such as an attribute of a resource being created in the same run. Until apply produces that value, the data source and everything downstream of it read as (known after apply).

Reads repeat on every run

A data source is not read once and cached forever. Terraform re-reads each data source on the refresh of every plan and apply, so it always reflects the current real-world value. A managed resource behaves differently: Terraform changes it only when your configuration or detected drift calls for a change. That is the practical run-time split between the two block types. The data source re-observes reality each run, while the resource is reconciled toward your configuration.

data sourceAll arguments known at plan?yesnoRead in the refresh phaseValue appears in the planDeferred to the apply phaseShows (known after apply)
Terraform reads a data source during refresh when its inputs are known, and defers to apply when an input is not yet known.

Provisioners and terraform_data: the last resort

Reach for a provisioner only after every declarative option is exhausted. HashiCorp designs Terraform for immutable infrastructure and recommends purpose-built solutions for post-apply steps[5], because a provisioner runs an opaque imperative command that Terraform cannot model, plan, or reconcile. When you do need one, the type you choose decides where the command runs.

Three provisioner types, two locations

Creation-time by default, destroy-time on request

Provisioners run at creation by default. Set when = destroy to run one during terraform destroy instead. Failure handling differs by phase, and the default catches people out:

terraform_data: a resource to hang provisioners on

A provisioner must live inside a resource, so what do you do when no real resource fits? Use terraform_data, a built-in managed resource that follows the standard lifecycle without managing any real object[7]. It is always available through the built-in provider terraform.io/builtin/terraform, with no required_providers entry or provider block[7], and it is the modern replacement for the null provider's null_resource. Its optional input argument stores a value that Terraform echoes to the computed output attribute (unknown during plan, equal to input after apply), and setting triggers_replace forces the instance to be replaced when that value changes, which you can chain into another resource's lifecycle { replace_triggered_by = [...] }.

A provisioner is a last resortOn the Terraform hostlocal-execno connection neededOn the remote resourceremote-execfileconnection block: SSH / WinRM
A provisioner runs either on the Terraform host (local-exec) or on the new resource through a connection block (remote-exec, file).

Exam-pattern recognition

The exam rarely asks for a definition outright. It shows a snippet or a scenario and asks which block, which meta-argument, or which command fits. The recurring patterns:

  • A stem shows configuration that reads an object that already exists (an AMI, a VPC, the current account) and asks how to reference it. The answer is a data block; distractors propose a resource block, which would try to create or take over the object. A data source cannot create or destroy anything.
  • A stem references aws_instance.web.id on a resource that sets count and asks why the plan errors. With count the name is a list, so you must index it: aws_instance.web[0].id. The for_each counterpart uses a string key, aws_instance.web["blue"].id, never a numeric index.
  • A stem sets both count and for_each on one block. That is always invalid: the two are mutually exclusive.
  • A stem omits the data. prefix when referencing a data source, or asks which prefix distinguishes a data source from a managed resource. The data. prefix is the answer.
  • A stem needs to run a shell command or copy a file onto a new server. The answer is a provisioner, but the correct framing is that it is a last resort: local-exec for the Terraform host, remote-exec or file for the server through a connection block. An option offering a provider resource or instance user data is often the better real-world choice the question is steering you toward.
  • A stem wants to run provisioners but has no natural resource to attach them to, or mentions replacing null_resource. The answer is terraform_data, which needs no provider configured.
  • A stem describes a provisioner that failed during creation and asks about the resource's state. It is tainted, so the next terraform apply destroys and recreates it; on_failure = continue is the only setting that would have let the apply proceed.

resource block vs data block

Aspectresource blockdata block
PurposeManage a real objectRead an existing object
Lifecycle actionsCreate, update, destroyRead only, never writes
When it runsPlanned, then appliedRefresh phase; deferred to apply if inputs are unknown
Reference formaws_instance.web.iddata.aws_ami.ubuntu.id
Meta-argumentsdepends_on, count, for_each, provider, lifecycleSame set, plus precondition and postcondition checks

Decision tree

Only reading an existing object?noyesdata blockread-only lookupTerraform owns its lifecycle?noyesresource blockmanaged lifecycleImperative step, no provider?noyesprovisioner (last resort)on a resource, or terraform_dataUse a provider resourceno provisioner needed

Sharp facts the exam loves — give these one last read before exam day.

Cheat sheet

Sharp facts the exam loves — scan these before test day.

Resource block has two labels

A resource block requires two labels: the resource type (e.g. aws_instance) and a local name (e.g. web). Together they form the resource address aws_instance.web, which must be unique within the module.

Trap Believing the local name must be globally unique or must match a provider-assigned identifier.

3 questions test this
Resource meta-arguments

In addition to provider-defined arguments, a resource block accepts the meta-arguments depends_on, count, for_each, provider, and lifecycle. count and for_each are mutually exclusive within the same block.

Trap Using both count and for_each in one resource block (Terraform errors).

3 questions test this
Managed resource lifecycle

A managed resource block directs Terraform to create, update, or destroy real infrastructure so it matches the configuration.

3 questions test this
count vs. for_each resource meta-arguments

The count meta-argument takes a whole number and creates that many instances addressed by count.index (starting at 0), while for_each takes a map or a set of strings and creates one instance per element keyed by each.key/each.value; you cannot set both count and for_each on the same block. for_each is preferred when instances need stable identity, because instances are tracked by map/set key rather than by positional index, so adding or removing a middle element does not force the shift-and-replace churn that count produces.

Trap count.index is unavailable inside a for_each block (and each.key/each.value are unavailable with count); switching a resource from count to for_each changes every instance address and forces replacement unless you add a moved block.

3 questions test this
Data blocks are read-only

A data source is declared data "<TYPE>" "<NAME>" {}; Terraform performs only read operations on it, fetching information about existing objects without creating or modifying infrastructure.

4 questions test this
When data sources are read

Terraform reads a data source during the plan/refresh phase when its arguments are known, but defers the read until apply when the data block depends on values not yet known (for example attributes of resources changing in the same plan).

Trap Assuming data sources are always read at apply time, or always fully resolved before plan.

4 questions test this
Data source reference syntax

Data source attributes are referenced as data.<TYPE>.<NAME>.<ATTRIBUTE>; the leading data. prefix distinguishes them from managed resource references.

Trap Omitting the data. prefix and referencing the source like a managed resource.

2 questions test this
CRUD ownership differs

A managed resource block owns the full create/update/delete lifecycle of the object, while a data block only reads an existing object and never manages or destroys it.

Trap Thinking a data source can provision or destroy infrastructure.

7 questions test this
Data sources re-read each run

Terraform re-reads each data source during the refresh step of every plan/apply so it reflects the current real-world value, whereas a managed resource is only changed when configuration or state drift requires it.

5 questions test this
provisioners: local-exec vs. remote-exec

A local-exec provisioner runs a command on the machine running Terraform, whereas a remote-exec provisioner runs commands on the newly created remote resource and therefore requires a connection block (SSH or WinRM). Provisioners run at creation time by default, or during destroy when when = destroy is set, and HashiCorp guidance is to use them only as a last resort after purpose-built alternatives.

Trap A failed creation-time provisioner marks the resource as tainted so it is recreated on the next apply; destroy-time provisioners still require a valid connection at destroy time.

8 questions test this
Provisioner failure taints the resource

A creation-time provisioner that exits non-zero fails the apply and marks the resource as tainted, so Terraform destroys and recreates it on the next terraform apply; because Terraform cannot predictably model the changes a provisioner makes, recreation is used instead of a partial repair. The on_failure argument overrides this: the default (fail) aborts the apply, while on_failure = continue makes Terraform ignore the error and proceed.

Trap Assuming a failed provisioner leaves a usable resource, or that continue is the default (it is not — fail is). Destroy-time provisioners behave differently: a failure returns an error and Terraform re-runs the provisioner on the next terraform apply rather than tainting.

5 questions test this
The file provisioner

The file provisioner copies files or directories from the machine where Terraform is running to the newly created resource, using either a source path or inline content plus a destination path on the target. Like remote-exec, it reaches the resource through a connection block over SSH or WinRM.

Trap file completes the provisioner-type triad (local-exec, remote-exec, file); a common misconception is treating file as a generic data/config feature or forgetting it still requires a connection block — unlike local-exec it acts on the remote host, not the Terraform machine.

terraform_data built-in resource (null_resource replacement)

The built-in terraform_data managed resource (added in Terraform 1.4, provider source terraform.io/builtin/terraform, requiring no provider block or required_providers entry) is the modern replacement for the null provider's null_resource: it implements the standard resource lifecycle without managing any real infrastructure object. Its optional input argument stores an arbitrary value that is echoed to the computed output attribute (unknown during plan, equal to input after apply), and it can host standalone provisioners that have no other logical managed resource to attach to. Setting its triggers_replace argument forces the terraform_data instance to be replaced whenever that value changes, so referencing the instance from another resource's lifecycle { replace_triggered_by = [terraform_data.] } propagates a forced replacement onto that dependent resource.

Trap Assuming terraform_data still needs the hashicorp/null provider (a required_providers entry or provider block) the way null_resource did — it does not; terraform_data is always available through Terraform's built-in provider, so no provider is declared or configured.

References

  1. Resource blocks (Terraform language)
  2. Data sources (Terraform language)
  3. The count meta-argument (Terraform language)
  4. The for_each meta-argument (Terraform language)
  5. Provisioners (Terraform language)
  6. remote-exec provisioner (Terraform language)
  7. The terraform_data managed resource (Terraform language)
  8. local-exec provisioner (Terraform language)
  9. file provisioner (Terraform language)