The resource block is the heart of Terraform — it is the thing that actually creates infrastructure. You can write a perfectly valid configuration with no variables, no modules, and no outputs, but without at least one resource block Terraform does nothing. And yet most engineers use only a fraction of what a resource block can do. They know type, name, and a handful of arguments, and they reach for copy-paste the moment they need three of something. The difference between an engineer who uses Terraform and one who commands it lies almost entirely in the meta-arguments — the small set of special arguments (count, for_each, depends_on, provider, and the lifecycle block) that every resource type accepts regardless of which provider it comes from. They control how many of a resource exist, in what order resources are created and destroyed, which provider manages them, and how Terraform treats changes and deletions — including the safety rails that stop a plan from quietly destroying your production database.
This lesson is deliberately exhaustive. We start with the anatomy of a resource block and how every resource is addressed — because addressing is the thread that connects count, for_each, state operations, and the dependency graph. We cover data sources, the read-only cousin of resources. Then we go meta-argument by meta-argument: count and its index model and empty-list toggle; for_each over maps and sets, why its key-based addresses make it the right default, and the precise rules for converting from one to the other; depends_on and the difference between implicit and explicit dependencies; the provider meta-argument for multi-region and multi-account work; and finally the lifecycle block in full — create_before_destroy, prevent_destroy, ignore_changes (attributes and all), replace_triggered_by, and the often-missed precondition and postcondition validation blocks. We finish with the dependency graph that ties it all together and terraform_data for trigger-driven replacement. Everything here applies equally to OpenTofu, the open-source fork — the HCL and CLI are identical unless noted.
Learning objectives
After working through this lesson you will be able to:
- Read and write a resource block and address any instance precisely —
type.name,type.name[0],type.name["key"], and the module-qualified forms. - Use data sources to read existing infrastructure and feed it into managed resources.
- Choose correctly between
countandfor_each, explain the stable-address problem that makesfor_eachthe safer default, and convert between them without destroying resources. - Express dependencies with the implicit reference model and know exactly when an explicit
depends_onis required. - Use the
providermeta-argument to place a resource with a non-default (aliased) provider. - Apply every
lifecycleoption —create_before_destroy,prevent_destroy,ignore_changes,replace_triggered_by,precondition, andpostcondition— and explain the trade-off and gotcha of each. - Reason about the dependency graph, the create/destroy ordering it implies, and how to drive replacement with
terraform_data.
Prerequisites
You should be comfortable reading basic HCL — blocks, arguments, and simple expressions — and you should have run the init → plan → apply → destroy workflow at least once. If the terms state, provider, and dependency graph are new, read Terraform Fundamentals: HCL, Providers, State & the Core Workflow first; this lesson assumes those mental models and goes deep on the resource layer that sits on top of them. This is a core Fundamentals lesson in the Terraform Zero-to-Hero ladder, sitting just after providers and just before variables. Meta-arguments are some of the most heavily tested topics on the Terraform Associate exam, so the depth here is exam-aligned as well as practical.
Core concepts: the resource block, anatomy and addressing
A resource block declares a single kind of infrastructure object that Terraform should create, read, update, and delete to match your configuration. Its skeleton is always the same:
resource "aws_instance" "web" { # keyword "resource", a TYPE label, a NAME label
ami = "ami-0abc123" # argument (provider-specific)
instance_type = "t3.micro" # argument (provider-specific)
tags = { # nested argument (a map)
Name = "web"
}
lifecycle { # a meta-argument BLOCK
create_before_destroy = true
}
}
Two labels follow the resource keyword. The first is the resource type (aws_instance) — it is defined by a provider and dictates which arguments are legal; the prefix before the first underscore (aws) tells Terraform which provider owns it. The second is the local name (web) — your choice, and it only has to be unique among resources of the same type within the same module. The type and name together form the resource’s address.
Addressing is the single most important idea in this lesson, because every meta-argument is really a statement about addresses. Terraform tracks each managed object in state by its address; terraform state commands, the dependency graph, plan output, and -target all speak in addresses. The forms you will meet:
| Address form | Means | When it appears |
|---|---|---|
aws_instance.web |
The single instance of a resource declared without count/for_each |
The default — one resource, one address |
aws_instance.web[0] |
The instance at integer index 0 of a count-ed resource |
Whenever count is set (even count = 1) |
aws_instance.web["app"] |
The instance keyed by string "app" of a for_each-ed resource |
Whenever for_each is set |
module.network.aws_subnet.this[2] |
A resource inside a child module, indexed | Resources created by modules |
data.aws_ami.ubuntu |
A data source (read-only), addressed under the data. prefix |
Reads, never managed |
Note the consequence that trips up everyone once: adding count or for_each to a resource that previously had neither changes its address. aws_instance.web becomes aws_instance.web[0] (or ["app"]), and Terraform — which keys state by address — sees the old address vanish and a new one appear. Without intervention it will plan to destroy and recreate the resource. The clean fix is a moved block (covered in the refactoring lesson, cross-linked below); the crude fix is terraform state mv. Keep this in mind through everything that follows.
A few more anatomy facts worth stating plainly:
- Arguments vs attributes. You set arguments (inputs). After apply, the resource exposes attributes (outputs) you can reference — some are arguments echoed back, others are computed by the provider (an
id, an ARN, an IP).aws_instance.web.private_ipis a computed attribute. - Computed / known-after-apply. If an attribute’s value cannot be known until apply (an auto-generated ID),
planshows it as(known after apply). This matters forfor_each, as we will see. - Sensitive attributes. Some attributes are marked sensitive by the provider and are redacted in plan/CLI output, though they still land in state in plaintext.
- Resource behaviour types. Most resources support full CRUD. Some changes are done in place (an
~update in the plan), others force a replace (-/+, destroy then create) because the underlying API cannot change that attribute live. The plan tells you which, and thelifecycleblock lets you influence it.
Data sources: the read-only cousin
A data source uses a data block to read information about infrastructure that already exists — whether created outside Terraform, in another configuration, or by a different team — without managing it. It never appears in your destroy plan because Terraform does not own it.
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id # consume the data source's attribute
instance_type = "t3.micro"
}
| Aspect | resource block |
data block |
|---|---|---|
| Purpose | Create/update/delete & manage an object | Read attributes of an existing object |
| Address prefix | type.name |
data.type.name |
| Lifecycle | Full CRUD; appears in destroy | Read-only; never destroyed |
| When it runs | During apply (and refresh) | During plan/refresh (or apply if its inputs are known-after-apply) |
| Meta-arguments | count, for_each, depends_on, provider, lifecycle (all) |
count, for_each, provider, and depends_on; lifecycle supports precondition/postcondition only |
Data sources take the same count and for_each meta-arguments as resources, addressed identically (data.aws_subnet.this["a"]). A subtle but important rule: if a data source’s arguments depend on values that are unknown until apply (for example a filter built from a not-yet-created resource), Terraform defers the read until apply, and any plan that depends on its results shows (known after apply). Adding depends_on to a data source forces the same deferral on purpose — useful when a read must happen after some resource is in place but there is no attribute to reference.
The meta-arguments, in full
Five meta-arguments are valid on (almost) every resource regardless of provider. Here they are at a glance before we take each in depth:
| Meta-argument | Form | What it controls | Valid on data sources? |
|---|---|---|---|
count |
count = <number> |
How many identical instances; index-addressed [0], [1]… |
Yes |
for_each |
for_each = <map|set> |
One instance per element; key-addressed ["k"] |
Yes |
depends_on |
depends_on = [<refs>] |
Explicit ordering when no attribute reference links resources | Yes |
provider |
provider = <type.alias> |
Which (aliased) provider configuration manages this resource | Yes |
lifecycle |
lifecycle { … } |
Create/destroy ordering, deletion protection, change-ignoring, replacement triggers, pre/post conditions | Partly (conditions only) |
A hard rule to memorise: count and for_each are mutually exclusive on the same block — you may use one or the other, never both.
count: the simplest multiplier
count accepts a whole number and creates that many instances of the resource, each addressed by a zero-based integer index and each exposing a count.index value inside the block.
resource "aws_instance" "worker" {
count = 3
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = {
Name = "worker-${count.index}" # worker-0, worker-1, worker-2
}
}
# Addresses: aws_instance.worker[0], [1], [2]
# Reference all: aws_instance.worker[*].id (a list via the splat)
# Reference one: aws_instance.worker[1].private_ip
count.index is the only special symbol count provides, and it is an integer (0 to count − 1). To turn a list into indexed resources you typically index the list by count.index:
variable "subnet_cidrs" {
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
resource "aws_subnet" "this" {
count = length(var.subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.subnet_cidrs[count.index]
}
The conditional (zero-or-one) toggle
The most idiomatic use of count has nothing to do with counting many things — it is the on/off switch. Because count = 0 creates no instances, a boolean-driven count makes a resource conditional:
variable "create_bastion" {
type = bool
default = false
}
resource "aws_instance" "bastion" {
count = var.create_bastion ? 1 : 0 # 1 = exists, 0 = absent
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
}
# Referencing a maybe-absent resource needs the index and a guard:
output "bastion_ip" {
value = var.create_bastion ? aws_instance.bastion[0].public_ip : null
}
This is the standard pattern for “deploy this only in prod” or “create the resource only if a feature flag is set”. Note that even a single conditional instance lives at index [0], never the bare address — referencing aws_instance.bastion.public_ip (no index) is an error when count is set.
The fatal flaw of count: positional addresses
count addresses by position, and positions shift. Suppose you manage three users from a list and remove the middle one:
variable "users" { default = ["alice", "bob", "carol"] }
resource "aws_iam_user" "team" {
count = length(var.users)
name = var.users[count.index]
}
# State: team[0]=alice, team[1]=bob, team[2]=carol
Drop "bob", leaving ["alice", "carol"]. Now the list is shorter, so carol slides from index 2 to index 1. Terraform compares by address:
team[0]— stillalice, no change.team[1]— wasbob, nowcarol→ Terraform plans to modify in place or replace, renaming bob to carol.team[2]— no longer exists → Terraform plans to destroy it.
You asked to delete one user; Terraform proposes to destroy one and mutate another. For IAM users this is merely noisy; for stateful resources (a database, a disk, a persistent volume) this positional shuffle is data-destroying. This is the single most important reason to prefer for_each whenever the set of things has stable identities.
Use count when: the instances are genuinely identical and interchangeable and the number is what matters (a fixed-size pool of stateless workers), or you need the zero/one conditional toggle, or you are iterating a list where order is irrelevant and the list never has middle elements removed.
for_each: one instance per keyed element
for_each creates one instance per element of a map or a set of strings, addressing each by a stable string key rather than a position. Inside the block you get two symbols: each.key and each.value.
# --- over a SET of strings: key == value ---
resource "aws_iam_user" "team" {
for_each = toset(["alice", "bob", "carol"])
name = each.key # for a set, each.key == each.value
}
# Addresses: aws_iam_user.team["alice"], ["bob"], ["carol"]
# --- over a MAP: key and value differ ---
resource "aws_instance" "svc" {
for_each = {
web = "t3.small"
cache = "r6g.large"
db = "m6i.xlarge"
}
ami = data.aws_ami.ubuntu.id
instance_type = each.value # the map value
tags = { Name = each.key } # the map key
}
# Addresses: aws_instance.svc["web"], ["cache"], ["db"]
Now remove "bob" from the set. Because addresses are keyed by name, Terraform sees exactly one change:
team["alice"]— unchanged.team["bob"]— gone → destroy (only this one).team["carol"]— unchanged (its key never moved).
That is the whole point: for_each gives every instance a stable, meaningful address, so adding or removing one element touches only that element. This is why for_each is the modern default for any collection whose members have identities.
each.key / each.value reference table
| Symbol | With for_each over a set |
With for_each over a map |
|---|---|---|
each.key |
The element’s string value | The map key |
each.value |
The element’s string value (same as key) | The map value (any type) |
The rules and constraints of for_each
for_each is stricter than count, and the constraints are exam favourites:
| Rule | Detail | Why / fix |
|---|---|---|
| Argument type | Must be a map or a set(string) |
A list is not allowed — convert with toset(...). Lists imply order/duplicates, which keys cannot have. |
| Keys must be known at plan time | The keys (map keys / set members) cannot be values that are “known after apply” | You may not key for_each on an attribute computed by another resource (e.g. a generated ID). Values can be unknown; keys cannot. |
| No duplicate keys | A set cannot contain duplicates; map keys are unique by definition | toset(["a","a"]) collapses to one — silently fewer instances than you might expect. |
each only inside the block |
each.key/each.value are scoped to the resource using for_each |
Outside it, iterate with a for expression instead. |
| Splat differs | for_each resources are a map of instances, not a list |
Use values(aws_instance.svc)[*].id or a for expression; bare [*] is for count. |
The “keys must be known at plan time” rule is the one people hit hardest. If you write for_each = toset(aws_subnet.this[*].id) — keying on IDs that do not exist until apply — Terraform errors with “the for_each value depends on resource attributes that cannot be determined until apply”. The fix is to key on something you control (names, CIDRs) and look up the computed value inside, or to split the apply with -target as a last resort.
Turning a list into a map for for_each
Real input often arrives as a list of objects. The idiomatic conversion is a for expression that projects a stable key:
variable "subnets" {
type = list(object({
name = string
cidr = string
az = string
}))
}
resource "aws_subnet" "this" {
for_each = { for s in var.subnets : s.name => s } # key by the stable "name"
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr
availability_zone = each.value.az
tags = { Name = each.key }
}
Keying by s.name (not by count.index) means reordering the list, or inserting an entry in the middle, never disturbs the existing subnets — exactly the stability count cannot give you.
count vs for_each: the exhaustive comparison
This is the decision the rest of your Terraform life turns on. The full contrast:
| Dimension | count |
for_each |
|---|---|---|
| Accepts | A whole number | A map or set(string) |
| Instance address | Integer index — name[0], name[1] |
String key — name["app"] |
| Special symbols | count.index (int) |
each.key, each.value |
| Add/remove a middle element | Re-indexes everything after it → cascade of changes/replacements | Touches only the affected key |
| Address stability | Positional — fragile | Key-based — stable |
| Conditional create | count = cond ? 1 : 0 (idiomatic) |
for_each = cond ? {...} : {} (works, more verbose) |
| Keys known at plan time? | N/A (count can derive from computed length if the list itself is known) |
Keys must be known at plan time |
| Collection of instances | A list — splat name[*] |
A map — values(name), name["k"] |
| Best for | Fixed pools of identical, interchangeable things; on/off toggles | Anything whose members have identities (named subnets, users, buckets, rules) |
| Duplicates | Allowed (list values can repeat) | Not allowed (set/map keys are unique) |
The rule of thumb that interviewers want to hear: use for_each by default; reach for count only for a fixed number of truly interchangeable instances or for the zero/one conditional toggle. The reason is always the same — stable addresses prevent accidental destroy-and-recreate when the collection changes.
One more practical note: you can use count or for_each on modules too, with identical semantics (module.app["web"]), and on data sources. The address rules carry over unchanged.
depends_on: explicit ordering
Terraform almost never needs to be told about ordering, because it infers dependencies from references. When resource A’s argument reads resource B’s attribute, Terraform knows B must exist first. That is an implicit dependency, and it is the mechanism you should rely on 95% of the time:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "app" {
vpc_id = aws_vpc.main.id # implicit dependency: subnet waits for the VPC
cidr_block = "10.0.1.0/24"
}
depends_on is the explicit override for the cases where a real dependency exists but is invisible to Terraform because no attribute connects the two resources. The classic example is an IAM policy that must be attached before an instance can use it, where the instance configuration never references the policy:
resource "aws_iam_role_policy" "s3" {
role = aws_iam_role.app.id
policy = data.aws_iam_policy_document.s3.json
}
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
iam_instance_profile = aws_iam_instance_profile.app.name
# The app's startup script writes to S3, which only works once the
# role policy is attached — but nothing here *references* that policy:
depends_on = [aws_iam_role_policy.s3]
}
| Dependency kind | How it is created | Use it when |
|---|---|---|
| Implicit | One resource references another’s attribute (vpc_id = aws_vpc.main.id) |
Always, if any attribute links them — it is automatic and precise |
Explicit (depends_on) |
A literal list of resource/module addresses with no attribute reference | A hidden ordering exists (eventual-consistency, side effects, IAM, “must be ready before”) that Terraform cannot see |
Rules and cautions for depends_on:
- It takes a list of references to whole resources or modules —
[aws_iam_role_policy.s3],[module.network]— not references to specific attributes. - Overusing it serialises your graph and slows applies; every false dependency you add removes parallelism. Prefer a real attribute reference whenever one is available.
- On a module,
depends_onmakes everything in the module wait for the listed dependencies — a blunt but sometimes necessary instrument. - On a data source,
depends_onforces the read to happen after the dependency and defers it to apply time (the value becomes known-after-apply).
The provider meta-argument
By default a resource is managed by the default configuration of its provider (the unlabelled provider "aws" {} block). When you have multiple configurations of the same provider — for multiple regions or accounts — you give the extra ones an alias and select one per resource with the provider meta-argument.
provider "aws" { # default — us-east-1
region = "us-east-1"
}
provider "aws" { # aliased — eu-west-1
alias = "europe"
region = "eu-west-1"
}
resource "aws_s3_bucket" "us" {
bucket = "kv-data-us" # uses the DEFAULT provider implicitly
}
resource "aws_s3_bucket" "eu" {
provider = aws.europe # explicitly use the aliased config
bucket = "kv-data-eu"
}
Key facts: the value is <provider>.<alias> (no quotes — it is a reference, not a string); without it the default configuration is used; and for modules you pass aliased providers down via the providers = { aws = aws.europe } map rather than the provider meta-argument. Aliases and version constraints are covered fully in Terraform Providers, In Depth: required_providers, Versions, Aliases & the Lock File; here we only need the meta-argument that consumes them.
The lifecycle block: every option
The lifecycle nested block changes how Terraform treats the resource’s creation, replacement, deletion, and change-detection. Its arguments take literal values only (with one exception, ignore_changes, which takes attribute references). Here is the full surface before we take each in turn:
| Option | Type | Purpose | One-line gotcha |
|---|---|---|---|
create_before_destroy |
bool | Create the replacement before destroying the old one (zero-downtime replace) | The new object must not clash with the old on any unique name/port/identifier |
prevent_destroy |
bool | Make any plan that would destroy this resource error out | Blocks terraform destroy too; must be removed (not just toggled) to delete |
ignore_changes |
list of attribute refs, or all |
Stop Terraform reverting drift on the listed attributes | Suppresses updates to those attrs; does not stop replacement triggered elsewhere |
replace_triggered_by |
list of refs | Force replacement when a referenced resource/attribute changes | References must be to managed resources/attributes, not variables |
precondition |
block (condition + error_message) |
Assert an assumption before the resource is created/updated | Evaluated during plan; fails fast with your message |
postcondition |
block (condition + error_message) |
Assert a guarantee about the result after apply | Can reference self; catches bad outcomes the type cannot prevent |
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
lifecycle {
create_before_destroy = true
prevent_destroy = false
ignore_changes = [tags["LastScanned"], ami]
replace_triggered_by = [terraform_data.deploy_version]
precondition {
condition = data.aws_ami.ubuntu.architecture == "x86_64"
error_message = "The selected AMI must be x86_64 for this instance family."
}
postcondition {
condition = self.public_ip != ""
error_message = "The instance was created without a public IP; check the subnet's auto-assign setting."
}
}
}
create_before_destroy
By default Terraform destroys then creates when an attribute change forces a replacement — there is a window where the resource does not exist. Setting create_before_destroy = true inverts this: Terraform creates the replacement first, switches references to it, then destroys the old one. This is how you achieve zero-downtime replacement of things like launch configurations, instances behind a load balancer, or TLS certificates.
- Trade-off: during the swap both instances exist, so you momentarily consume double the resource (and may brush against quotas).
- The naming gotcha: if the resource has any attribute that must be globally or regionally unique — a fixed
name, an S3 bucket name, a static port — creating the new one before destroying the old one fails with a conflict. The remedy is to let the provider generate the name (name_prefixinstead ofname) so the two coexist. - Propagation:
create_before_destroyis “infectious” — any resource that depends on acreate_before_destroyresource must also adopt it, or Terraform will tell you the graph cannot be ordered. Terraform 1.x usually handles this automatically for direct dependents, but you will see errors if a dependency cannot honour the ordering.
prevent_destroy
prevent_destroy = true makes Terraform reject any plan that would destroy this resource, erroring at plan time before anything happens. It is the guardrail for irreplaceable, stateful resources — production databases, the state bucket itself, a KMS key.
- It blocks
terraform destroyand any change that would replace the resource (because a replace destroys the old one). - It is not a substitute for cloud-side deletion protection (RDS
deletion_protection, S3 bucket policies); it only stops Terraform from issuing the delete. Someone deleting via the console is unaffected. - The gotcha: to legitimately delete the resource you must remove the
prevent_destroyline (or set it false) and apply that change first, then destroy — a flag set from a variable will error becauseprevent_destroymust be a literal (it cannot reference variables). This is by design: deletion protection you can flip with a-varis not protection.
ignore_changes
ignore_changes tells Terraform to stop trying to “correct” specified attributes when they drift from the configuration. It takes a list of attribute references (bare names, not strings):
lifecycle {
ignore_changes = [
tags["LastModifiedBy"], # an autoscaler or another system writes this tag
desired_count, # an autoscaler manages the count; don't fight it
]
}
After the resource is created, Terraform will no longer plan updates to those attributes even if the real value differs from the config. The canonical use cases are attributes managed by something other than Terraform — autoscaling adjusting desired_count, a deployment system bumping an image tag, a platform stamping tags, or an initial_password you set once and the cloud then rotates.
- You may pass the special token
all—ignore_changes = all— to ignore changes to every attribute after creation. Terraform will create the resource and then never update it in place again. This is occasionally right for “create once, then leave entirely to another owner”, but it is a sledgehammer: real, intended config changes will also be silently ignored, so use it sparingly. - The crucial limit:
ignore_changesonly suppresses in-place updates to the listed attributes. It does not prevent the resource from being replaced if some other change (orreplace_triggered_by, or a-replace) forces it. Ignoringamistops AMI drift from causing an update, but a change toinstance_typecan still replace the whole instance. - It accepts top-level argument names and map/index sub-paths (
tags["X"]), but not arbitrary deep expressions.
replace_triggered_by
replace_triggered_by (added in Terraform 1.2 / present in OpenTofu) forces this resource to be replaced whenever a referenced resource or attribute changes, even though nothing about this resource’s own arguments changed. It takes a list of references to managed resources, instances, or their attributes:
resource "terraform_data" "deploy" {
input = var.app_version # changes when you ship a new version
}
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
lifecycle {
# Replace the instance every time app_version changes, even though
# none of the instance's own arguments changed:
replace_triggered_by = [terraform_data.deploy]
}
}
Use it to couple replacement to an external signal — “rebuild the instance when the deployment version changes”, “recreate the cache when the config object changes”. References must point at managed resources/attributes (you cannot replace_triggered_by a variable directly — wrap the variable in a terraform_data resource, as above). Referencing a whole resource triggers replacement when any of its attributes change; referencing a single attribute narrows the trigger.
precondition and postcondition (custom condition checks)
These two nested blocks turn assumptions and guarantees into enforced, self-documenting assertions — part of Terraform’s custom conditions feature (1.2+, and in OpenTofu). Each contains a condition (a boolean expression that must be true) and an error_message (shown when it is false).
- A
preconditionis checked before the resource is created or updated — it asserts something must be true for this resource to make sense. Think of it as a guard at the front door: validate inputs, the chosen image’s architecture, a region’s capability, a CIDR’s size. If it fails, the plan/apply stops with your message before anything is built. - A
postconditionis checked after the resource’s planned values are known / after apply — it asserts something about the result. It can referenceself(the resource’s own attributes), making it ideal for catching outcomes the resource type itself does not validate: “the instance actually got a public IP”, “the bucket region matches what we expected”.
data "aws_ec2_instance_type" "this" {
instance_type = var.instance_type
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = var.subnet_id
lifecycle {
precondition {
condition = data.aws_ec2_instance_type.this.ebs_optimized_support != "unsupported"
error_message = "instance_type ${var.instance_type} does not support EBS optimisation, which this workload requires."
}
postcondition {
condition = self.private_ip != ""
error_message = "Instance launched without a private IP — the subnet may be misconfigured."
}
}
}
| Aspect | precondition |
postcondition |
|---|---|---|
| When evaluated | Before create/update (during plan, as soon as its inputs are known) | After the resource’s values are known (plan if known, else apply) |
Can reference self? |
No (the resource does not exist yet) | Yes — its own attributes |
| Typical use | Validate inputs/assumptions before building | Validate outcomes/guarantees after building |
| On failure | Halts with your error_message |
Halts with your error_message |
| Valid on data sources? | Yes | Yes |
Where do these sit among Terraform’s other validation tools? It is worth fixing the boundaries, because the exam and interviewers probe it:
| Check | Lives in | Best for |
|---|---|---|
Variable validation |
variable block |
Validating a single input value in isolation (a string matches a regex, a number is in range) |
precondition |
resource/data lifecycle |
Cross-cutting assumptions about inputs/other data before a specific resource is built |
postcondition |
resource/data lifecycle |
Guarantees about a resource’s own result (self.*) after it is built |
check block |
top-level check (1.5+) |
Non-blocking assertions/health checks that warn but never fail the apply |
The distinction that matters: variable validation is scoped to one variable; pre/postconditions can reference multiple values and a resource’s own attributes; and a check block (a separate top-level construct) reports problems as warnings without stopping the apply, which the others always do.
terraform_data and trigger-driven replacement
Often you want to force something to happen when an arbitrary value changes — re-run a provisioner, replace a resource (via replace_triggered_by), or simply store a value in state to depend on. The historical tool was the null_resource from the null provider with its triggers map. Terraform 1.4 introduced a built-in replacement that needs no provider: terraform_data (OpenTofu has it too).
resource "terraform_data" "config_version" {
input = var.config_hash # store any value in state
triggers_replace = [ # replace this resource when any of these change
var.config_hash,
var.app_version,
]
}
inputstores an arbitrary value;outputechoes it back (handy for passing a value through state).triggers_replacetakes a value (or list); whenever it changes, theterraform_dataresource is replaced, which you can then chain intoreplace_triggered_byon a real resource or into a provisioner’s lifecycle.- It is the modern, provider-free stand-in for
null_resource+triggers. Prefer it. (Provisioners themselves — and the few remaining reasons to usenull_resource— are covered in Terraform Provisioners, In Depth.)
| Need | Old way | Modern way |
|---|---|---|
| Store a value in state to depend on | null_resource + triggers |
terraform_data with input |
| Force replacement when X changes | null_resource triggers + downstream depends_on |
terraform_data + triggers_replace, consumed by replace_triggered_by |
| Run a provisioner on change | null_resource + provisioner |
terraform_data + provisioner (still a last resort) |
The dependency graph: how it all orders
Everything above feeds one thing: the dependency graph. Before any apply, Terraform builds a directed acyclic graph (DAG) whose nodes are resource instances and whose edges are dependencies. It derives edges from:
- Implicit references — every attribute one resource reads from another.
- Explicit
depends_on— edges you add by hand. replace_triggered_byand provider relationships — additional ordering constraints.
Terraform then walks the graph: it creates/updates resources in dependency order, parallelising independent branches (up to -parallelism, default 10), and on destroy it walks the graph in reverse, tearing down dependents before the things they depend on. count and for_each expand a single configured resource into multiple graph nodes (one per index/key), which is why a re-indexing change ripples through the graph. You can render the graph with terraform graph | dot -Tsvg > graph.svg.
The diagram shows a single resource block fanning out via count (positional [0..n]) and for_each (keyed ["a".."z"]) into distinct graph nodes, the implicit and depends_on edges that order them, and the lifecycle decision points — create_before_destroy reversing the destroy/create order, prevent_destroy halting a delete, ignore_changes filtering drift, and precondition/postcondition gating plan and apply.
Hands-on lab
We will do this entirely free and local using the local and random providers — no cloud account, no cost — so you can see count, for_each, and the whole lifecycle surface behave for real. You only need the Terraform (or OpenTofu) binary.
1. Scaffold the project. Create a folder tf-meta and a file main.tf:
terraform {
required_version = ">= 1.5"
required_providers {
local = { source = "hashicorp/local", version = "~> 2.5" }
random = { source = "hashicorp/random", version = "~> 3.6" }
}
}
# --- count: a fixed pool, index-addressed ---
resource "random_pet" "worker" {
count = 3
}
resource "local_file" "worker" {
count = length(random_pet.worker)
filename = "${path.module}/out/worker-${count.index}.txt"
content = "worker ${count.index}: ${random_pet.worker[count.index].id}\n"
}
# --- for_each: keyed instances over a map ---
variable "services" {
type = map(string)
default = {
web = "frontend"
cache = "redis"
db = "postgres"
}
}
resource "local_file" "service" {
for_each = var.services
filename = "${path.module}/out/service-${each.key}.txt"
content = "service ${each.key} runs ${each.value}\n"
lifecycle {
create_before_destroy = true
precondition {
condition = length(each.key) <= 10
error_message = "Service key '${each.key}' exceeds 10 characters."
}
}
}
# --- terraform_data driving replacement ---
variable "config_version" {
type = string
default = "v1"
}
resource "terraform_data" "deploy" {
input = var.config_version
triggers_replace = [var.config_version]
}
resource "local_file" "manifest" {
filename = "${path.module}/out/manifest.txt"
content = "deployed config: ${var.config_version}\n"
lifecycle {
replace_triggered_by = [terraform_data.deploy]
}
}
output "service_files" {
value = { for k, f in local_file.service : k => f.filename }
}
2. Initialise and apply.
terraform init
terraform apply -auto-approve
Expected: Terraform creates 3 random_pet, 3 worker-N.txt, 3 service-<key>.txt, the terraform_data.deploy, and manifest.txt. List them: ls out/ should show worker-0.txt worker-1.txt worker-2.txt service-web.txt service-cache.txt service-db.txt manifest.txt.
3. Watch for_each’s stable addressing. Remove cache from the services map and add queue = "rabbitmq", then plan:
terraform plan
Expected: exactly one destroy (local_file.service["cache"]) and one create (local_file.service["queue"]). The web and db files are untouched — their keys never moved. This is the payoff of for_each.
4. Contrast with count’s re-indexing. Temporarily change the worker pool from count = 3 to count = 2 and plan:
terraform plan
Expected: local_file.worker[2] is destroyed. Now imagine instead you had keyed workers off a list and removed a middle element — every later index would show as a change. Restore count = 3.
5. See replace_triggered_by fire. Change config_version to "v2":
terraform apply -auto-approve -var 'config_version=v2'
Expected: terraform_data.deploy is replaced, which forces local_file.manifest to be replaced (destroy/create), even though nothing about the manifest’s own content arguments changed beyond the version string.
6. Trip a precondition. Add a service with an over-long key, e.g. analyticsdashboard = "metabase", and plan:
terraform plan
Expected: the apply is refused with “Service key ‘analyticsdashboard’ exceeds 10 characters.” — the precondition caught a bad input before creating anything. Remove the entry.
7. Cleanup.
terraform destroy -auto-approve
Then remove the folder. Cost note: the local, random, and terraform_data resources create only files on your machine and state entries — there is zero cloud cost. This entire lab is free.
Common mistakes & troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Adding count/for_each plans to destroy & recreate an existing resource |
The address changed (name → name[0]/name["k"]); state keys by address |
Add a moved block, or terraform state mv the old address to the new — see the refactoring lesson |
Error: Invalid for_each argument … cannot be determined until apply |
You keyed for_each on a computed/known-after-apply value (e.g. a resource ID) |
Key on a value known at plan time (names/CIDRs); look up the computed value inside via each.key |
| Removing one list element causes many resources to change | count re-indexes positionally |
Migrate the resource to for_each keyed by a stable identity |
Error: Reference to undeclared resource when referencing a count=0 resource |
You referenced name.attr instead of name[0].attr, or the resource has 0 instances |
Use the index and guard with a conditional (cond ? name[0].attr : null) |
create_before_destroy fails with a name/identifier conflict |
A unique name exists on both old and new during the overlap | Use name_prefix (provider-generated unique name) instead of a fixed name |
terraform destroy errors and refuses to delete a resource |
prevent_destroy = true is set on it |
Remove the prevent_destroy line (it must be a literal), apply, then destroy |
ignore_changes does not stop a resource from being replaced |
ignore_changes only suppresses in-place updates; a different attribute or replace_triggered_by is forcing the replace |
Identify the replace-forcing attribute in the plan; ignore that, or accept the replacement |
for_each silently creates fewer instances than expected |
Duplicate set members collapsed (toset dedupes) or a for projection produced colliding keys |
Ensure the key expression is unique per element |
depends_on makes applies slow |
False/over-broad explicit dependencies serialise the graph | Replace with a real attribute reference; only keep depends_on for genuinely hidden deps |
Best practices
- Default to
for_each. Usecountonly for fixed pools of interchangeable instances and the zero/one conditional toggle. Stable, keyed addresses prevent the destroy-and-recreate cascade. - Key
for_eachon something stable and meaningful — a name, a CIDR, an environment — never on positional index and never on a computed attribute. - Lean on implicit dependencies. Add
depends_ononly when a real ordering is invisible to Terraform; every false edge costs you parallelism. - Reserve
prevent_destroyfor the irreplaceable — production data stores, the state backend, KMS keys — and keep it a literal so it cannot be flipped with a-var. - Use
create_before_destroyfor anything fronted by a load balancer or with live traffic, and pair it withname_prefixto avoid unique-name clashes. - Encode assumptions as
preconditions and guarantees aspostconditions rather than discovering them in a broken apply; they are documentation that fails loudly. - Scope
ignore_changesnarrowly to the specific attributes another system owns; never reach forallunless you truly mean “create once and never manage again”. - Prefer
terraform_dataovernull_resourcefor triggers and pass-through values — it needs no provider. - Refactor with
movedblocks, not destroy-and-recreate, whenever you restructurecount/for_eachor rename resources.
Security notes
Meta-arguments shape your security posture more than they appear to. prevent_destroy is a control-plane safety rail, not a security control: it stops Terraform deleting a resource but does nothing against a console user, so pair it with cloud-native deletion protection and least-privilege IAM on the principal Terraform runs as. ignore_changes can quietly hide drift that matters for security — if you ignore changes to a security group’s rules or a bucket policy because “something else manages them”, you also lose Terraform’s ability to detect that those rules were tampered with; ignore only attributes whose ownership genuinely lies elsewhere, and consider a check block to alert on the ignored values. precondition/postcondition are excellent security gates: assert that an AMI comes from an approved owner, that a bucket is in an allowed region, that encryption is enabled — failing the apply rather than shipping a misconfiguration. Finally, remember that for_each/count over secrets (looping to create many secret values) still writes every value to state in plaintext; the iteration does not change the state-sensitivity rules, so protect the backend accordingly.
Interview & exam questions
1. When would you choose for_each over count, and why?
Use for_each whenever the instances have stable identities (named subnets, users, buckets). count addresses by integer position, so removing or inserting a middle element re-indexes everything after it, causing spurious changes or destructive recreation. for_each addresses by key, so adding/removing one element touches only that element.
2. Give the one case where count is clearly the right tool.
The zero/one conditional toggle — count = var.enabled ? 1 : 0 — to make a resource conditional. Also a fixed-size pool of genuinely interchangeable, stateless instances where position is irrelevant.
3. What types can for_each accept, and what is the constraint on its keys?
A map or a set(string) (not a list — convert with toset). The keys must be known at plan time; they cannot depend on values computed during apply (like a resource ID). Values may be unknown; keys may not.
4. What is the difference between an implicit and an explicit dependency?
Implicit: created automatically when one resource references another’s attribute. Explicit: declared with depends_on when a real ordering exists but no attribute links the resources (IAM attachment, eventual consistency, side effects). Prefer implicit; depends_on removes parallelism.
5. Walk me through every lifecycle option.
create_before_destroy (create the replacement before destroying the old — zero-downtime, but watch unique names); prevent_destroy (error on any plan that would delete it); ignore_changes (stop reverting drift on listed attrs, or all); replace_triggered_by (replace when a referenced resource/attr changes); precondition (assert before create/update); postcondition (assert about the result, can use self).
6. ignore_changes = [ami] is set, yet the plan wants to replace the instance. Why?
ignore_changes only suppresses in-place updates to the named attributes. A different attribute changing (e.g. instance_type), or a replace_triggered_by/-replace, can still force a full replacement. Ignoring ami does not pin the whole resource.
7. Difference between a variable validation block, a precondition, and a check block?
validation validates a single input variable in isolation. precondition/postcondition live in a resource/data lifecycle and can reference multiple values (and self for postconditions); they fail the plan/apply. A top-level check block runs non-blocking assertions that emit warnings without failing the apply.
8. What does create_before_destroy do to a resource with a fixed unique name, and how do you fix it?
It fails: the new instance is created while the old still holds the unique name/identifier, causing a conflict. Fix by using name_prefix so the provider generates a unique name, letting both coexist during the swap.
9. How do you make a resource rebuild whenever an external version string changes?
Wrap the version in a terraform_data resource (triggers_replace = [var.version]) and reference it from the target resource’s lifecycle { replace_triggered_by = [terraform_data.deploy] }. (replace_triggered_by must reference a managed resource/attribute, not a variable.)
10. You add for_each to an existing single resource and the plan wants to destroy and recreate it. What happened and what is the clean fix?
Its address changed from type.name to type.name["key"]; Terraform keys state by address, so the old address looks deleted and the new one new. The clean fix is a moved { from = ..., to = ... } block (or terraform state mv) so Terraform treats it as a rename, not a replacement.
11. What replaced null_resource + triggers, and why is it better?
terraform_data, built into Terraform/OpenTofu core. It needs no null provider, supports input/output for pass-through values, and triggers_replace for change-driven replacement.
12. On a module, what does depends_on affect?
Every resource inside the module waits for the listed dependencies — it is module-wide. It is blunt; prefer wiring real attribute references between modules where possible.
Quick check
countaddresses instances by ______;for_eachaddresses them by ______.- True or false:
for_eachaccepts alist(string)directly. - Which
lifecycleoption creates the replacement before destroying the old resource? - You need a resource only when
var.enabledis true. Write the meta-argument. - Which two
lifecycleblocks are also valid on adatasource, and which one can referenceself?
Answers
- Integer index (
name[0]); string key (name["k"]). Position vs identity. - False. It accepts a
maporset(string); wrap a list withtoset(...). create_before_destroy = true— inverts the default destroy-then-create order for zero-downtime replacement.count = var.enabled ? 1 : 0(the idiomatic conditional toggle). The instance lives at index[0].preconditionandpostconditionare valid on data sources (the otherlifecycleoptions are not). Onlypostconditioncan referenceself, because the object exists by then.
Exercise
Take the lab’s for_each services map and harden it like production:
- Convert
var.servicesfrommap(string)to alist(object({ name = string, role = string, replicas = number })), then build thefor_eachmap with aforexpression keyed byname({ for s in var.services : s.name => s }). - Resist the urge to nest a
countfor the replicas. Keep exactly onelocal_fileper service (for_each), and express the replica count as an attribute of the content rather than as multiple resources. (This is the discriminator: distinct identities →for_each; a mere quantity within one identity → an attribute, notcount.) - Add a
preconditionto thelocal_file.serviceresource asserting everyreplicasvalue is between 1 and 5, with a clearerror_messagethat interpolateseach.key. - Add a
postconditionasserting the generatedcontentis non-empty (length(self.content) > 0). - Add a
terraform_data“release” resource keyed off avar.release_id, and makelocal_file.manifestusereplace_triggered_byso changingrelease_idrebuilds the manifest. apply; then reorder the list and confirm viaplanthat no existing service file changes (proving thefor_eachkey is stable). Finallydestroy.
Write two or three sentences contrasting what step 6’s plan showed against what count keyed off the list index would have shown — this stable-address contrast is the single most common Terraform interview discriminator.
Certification mapping
This lesson maps to the HashiCorp Certified: Terraform Associate (003) exam, principally the objectives Read, generate, and modify configuration and Understand Terraform basics. Specifically it covers: the resource block and data sources; the meta-arguments count, for_each, depends_on, and provider; resource addressing and how count/for_each change it; the lifecycle block — create_before_destroy, prevent_destroy, ignore_changes, replace_triggered_by, and precondition/postcondition; the dependency graph and implicit-vs-explicit dependencies; and terraform_data as the modern trigger primitive. Expect the exam to test count vs for_each (the re-indexing problem), the for_each plan-time key constraint, and what each lifecycle option does — all covered above. The adjacent Terraform Associate Prep Kit lesson drills these as practice questions.
Glossary
- Resource block — A
resource "type" "name" {}declaration of infrastructure Terraform creates and manages. - Resource type — The provider-defined kind (
aws_instance); the prefix names the owning provider. - Local name — Your label for a resource, unique among that type in the module; with the type it forms the address.
- Address — The identifier Terraform tracks a resource by:
type.name,type.name[0],type.name["k"],module.x.type.name. - Data source — A read-only
datablock that looks up existing infrastructure without managing it. - Meta-argument — A special argument valid on any resource regardless of provider:
count,for_each,depends_on,provider,lifecycle. count— Creates N instances addressed by integer index; exposescount.index.for_each— Creates one instance per map/set element addressed by key; exposeseach.key/each.value.- Implicit dependency — Ordering inferred when one resource references another’s attribute.
- Explicit dependency — Ordering forced with
depends_onwhen no attribute links the resources. lifecycle— A nested block controlling create/destroy order, deletion protection, change-ignoring, replacement triggers, and pre/post conditions.create_before_destroy— Create the replacement before destroying the old one (zero-downtime replace).prevent_destroy— Make any plan that would delete the resource fail.ignore_changes— Stop Terraform reverting drift on listed attributes (orall).replace_triggered_by— Force replacement when a referenced resource/attribute changes.precondition/postcondition— Custom condition checks asserting assumptions before / guarantees after; postconditions can useself.terraform_data— Built-in resource for storing values and triggering replacement (triggers_replace); replacesnull_resource.- Dependency graph (DAG) — The directed acyclic graph Terraform builds from dependencies to order create/destroy operations.
Next steps
You now command the resource layer — addressing, data sources, count vs for_each, dependencies, the provider meta-argument, and every lifecycle option including the pre/post condition checks. The natural next move is the layer that feeds these resources: inputs and their rules. Continue with Terraform Variables, Outputs & Locals, In Depth: Types, Validation, Sensitivity & Precedence, which covers variable type constraints (including optional() object attributes), validation blocks, the full variable-precedence order, outputs, and locals — the parameterisation that turns the resources you just mastered into reusable configuration. And when you need to restructure count/for_each or rename resources without destroying them, see Refactoring Terraform Safely: moved, import & removed Blocks.