Background

Resources and References

36 min read

The resource block is the only block that creates anything, and one reference from one resource to another is the mechanism behind ordering, dependency and most of Terraform's apparent intelligence. This page is the anatomy of that block, the difference between what you set and what you read, the address format every error message speaks in, and data sources — including the cases where reaching for one is the wrong instinct.

Prerequisites: HCL & the Type System, Providers & the Registry


What & Why

A resource is one object Terraform manages: it creates it, records it in state, updates it when the configuration changes, and destroys it when you remove the block. A reference is one resource using another's value, which is simultaneously how you avoid repeating yourself and how you tell Terraform what order to work in.

The bad practice it replaces

Hardcoded identifiers, copied between places. In a pre-IaC world — and in a surprising amount of Terraform written today — the bucket's ARN appears as a string literal in the policy, the subnet ID appears as a string literal in the instance, and each of those literals is a fact about the world that someone has to keep true by hand. When the bucket is recreated, the literal is stale, and nothing tells you.

A reference replaces the literal with a relationship. It's always correct, because it's read from the thing itself, and it carries ordering information for free. The single most common quality problem in real Terraform is a hardcoded value that should have been a reference — and the reason it matters goes beyond tidiness, because a hardcoded value also deletes a dependency edge (The Dependency Graph).

Where it sits

This is the page where configuration starts creating things. It covers the resource block, references, addresses, and data sources. It stops short of the five arguments that work on every resource — count, for_each, depends_on, lifecycle, provider — which are Meta-Arguments, and of what happens to a reference during planning, which is The Plan/Apply Lifecycle.

Three things they're confused with

A resource is not a cloud service. It's one object in one API. "Set up object storage properly" is one resource on GCP and five on AWS, because the providers made different decisions about granularity.

A data source is not a resource. It reads; it never creates, changes or destroys. It appears in state as a cached read, and removing it destroys nothing.

An attribute is not an argument. They're often spelled the same and they're different directions: you set arguments, you read attributes. Some attributes can only ever be read, because the provider computes them.

When NOT to use them

  • Don't use a data source to find something you also create. If the same configuration manages the bucket, reference the resource directly. A data source looking up your own resource is slower, can produce a cycle, and silently returns stale information.
  • Don't use a data source where a variable would do. Looking up the current region or account to build a name makes your plan depend on ambient context; an explicit input is clearer and reproducible. Some lookups are worth it; make it a decision.
  • Don't use terraform_data or null_resource to run commands. They exist, and the combination of either with a provisioner is the standard way people smuggle imperative scripting into a declarative tool. The result runs only at creation, is invisible to plan, and never re-runs when the script changes.
  • Don't name a resource after its type. aws_s3_bucket.s3_bucket reads as aws_s3_bucket.s3_bucket.id everywhere forever. Name it for its role, or this if there's only one.
  • Don't rename a resource casually. The name is part of the address, and the address is the identity in state. Renaming without a moved block destroys and recreates.

Core Concepts

Resourceone managed object. Declared with a resource block; created, tracked in state, and destroyed when removed from configuration.

Resource typewhat kind of thing. aws_s3_bucket. The prefix before the first underscore identifies the provider's local name, which is how Terraform knows which plugin to ask.

Resource nameyour label for this instance. The second string in the block header. Local to the configuration, arbitrary, and must be unique within its type. Never appears in the cloud.

Argumenta value you set. bucket = "example". Required or optional; optional ones fall back to a provider default when omitted or set to null.

Attributea value you can read. aws_s3_bucket.demo.arn. Some attributes correspond to arguments you set; others are computed — produced by the provider and readable only.

Computed attributea value the provider decides. An ARN, a generated ID, an endpoint, a timestamp. Unknown until the object exists, which is why it renders as (known after apply) in a plan for a resource being created.

idevery resource has one. A provider-defined identifier, always a string. What it contains varies wildly: an S3 bucket's id is its name, an Azure resource's id is a full path. Treated specially by import, and not always the thing you want to reference.

Referencereading one object's attribute from another. type.name.attribute for resources, data.type.name.attribute for data sources. Creates a dependency edge.

Resource addressthe canonical identifier. [module.NAME.]TYPE.NAME[["key"]|[index]] — for example aws_s3_bucket.demo, or module.storage.aws_s3_bucket.demo["logs"]. This is the language every error message, plan line, state entry and CLI argument speaks. Learning to read it is the fastest way to make Terraform errors legible.

Data sourcea read-only lookup. Declared with a data block. Queries the provider for information about something Terraform doesn't manage — or manages elsewhere — and exposes it as attributes.

depends_on for data sourcesdeferral. A data source whose arguments are all known is read during planning; one that depends on a not-yet-created resource is deferred to apply and renders as <=.

Managed versus data modethe distinction state records. State marks each entry managed or data. Only managed resources are created, updated or destroyed.

Provider-computed defaultwhat happens when you omit an optional argument. The provider applies its own default, and that default can change between provider versions — which is one reason a provider upgrade can produce a plan.

terraform_dataa resource that does nothing. Terraform's built-in placeholder, replacing the older null_resource, mostly used to attach provisioner blocks or to force replacement via replace_triggered_by. Its legitimate uses are narrow. ⚠️ verify minimum version.


How It Works

Anatomy of a resource block

resource "aws_s3_bucket" "app_data" {
  #        └── type          └── name
  bucket        = "tf-resources-demo-0725"   # argument you set
  force_destroy = false                       # optional argument, provider default is false

  tags = {
    Environment = "dev"
  }
}

The header's two strings do different jobs. The type tells Terraform which provider to route to — aws_ maps to the provider with local name aws — and which schema to type-check against. The name is yours, exists only in the configuration and in state, and never appears in the cloud. bucket is the argument that determines the real-world name; the two are unrelated and can differ, which confuses people once.

Arguments versus attributes

The distinction is direction, and it's easiest to see in a single resource:

Diagram of a resource block with arguments flowing in and attributes flowing out, computed attributes marked as read-only

Set it? Read it? Example
Required argument Yes, must Yes bucket
Optional argument Yes, may Yes force_destroy, tags
Computed attribute No Yes arn, bucket_domain_name
id No Yes id

Attempting to set a computed attribute is an error:

# UNVERIFIED — confirm against a real run
Error: Value for unconfigurable attribute

  Can't configure a value for "arn": its value will be decided automatically
  based on the result of applying this configuration.

The provider's schema is the authority on which is which, and the registry documentation splits them into an "Argument Reference" and an "Attribute Reference" for exactly this reason. When something isn't working, checking which list a name is in resolves it faster than guessing.

References, and the two things they do

resource "aws_s3_bucket" "logs" {
  bucket = "tf-resources-demo-logs-0725"
}

resource "aws_s3_bucket_policy" "logs" {
  bucket = aws_s3_bucket.logs.id      # reference
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Deny"
      Principal = "*"
      Action    = "s3:*"
      Resource  = "${aws_s3_bucket.logs.arn}/*"   # reference, inside a string
      Condition = {
        Bool = { "aws:SecureTransport" = "false" }
      }
    }]
  })
}

That reference does two things at once, and the second is easy to miss:

  1. It supplies a correct value, read from the object itself rather than duplicated by hand.
  2. It creates a dependency edge, so the bucket is created before the policy.

Replace aws_s3_bucket.logs.id with the literal "tf-resources-demo-logs-0725" and the configuration still applies — most of the time. You've silently removed the edge, and the failure appears on a fresh apply into a new environment. Detail in The Dependency Graph; the point here is that a reference is not just a tidier literal.

References work anywhere an expression does — inside strings, inside jsonencode, in nested blocks, in other resources' arguments. There is no special syntax and no need to interpolate a bare reference: bucket = aws_s3_bucket.logs.id, not bucket = "${aws_s3_bucket.logs.id}".

(known after apply)

A computed attribute of a resource that doesn't exist yet has no value:

# UNVERIFIED — confirm against a real run
  # aws_s3_bucket_policy.logs will be created
  + resource "aws_s3_bucket_policy" "logs" {
      + bucket = (known after apply)
      + id     = (known after apply)
      + policy = (known after apply)
    }

Note that policy is unknown too, even though you wrote it — because it contains the bucket's ARN, and an expression containing an unknown value is itself unknown. That propagation rule, and the trouble it causes, is The Plan/Apply Lifecycle. Here it's enough to recognise the marker and know it means "the object doesn't exist yet".

The resource address, and why it's worth learning

aws_s3_bucket.demo                              a resource
aws_s3_bucket.demo["logs"]                      one instance of it, keyed
aws_s3_bucket.demo[0]                           one instance, indexed
module.storage.aws_s3_bucket.demo               inside a module
module.storage["eu"].aws_s3_bucket.demo         inside a keyed module instance
data.aws_caller_identity.current                a data source

Diagram breaking down a Terraform resource address into its module path, type, name and instance key components

Every one of these appears in plan output, in state list, in error messages, and as an argument to state mv, import, taint's replacement -replace=, and -target. So a single error line like this is fully legible once you can parse the address:

# UNVERIFIED — confirm against a real run
Error: creating S3 bucket: BucketAlreadyExists

  with module.storage["eu"].aws_s3_bucket.demo["logs"],
  on ../../modules/storage/main.tf line 12, in resource "aws_s3_bucket" "demo":
  12: resource "aws_s3_bucket" "demo" {

That tells you: the eu instance of the storage module, the logs instance of the bucket, and the file and line. Nothing else needs looking up.

The address is also the identity in state, which is why renaming the resource — or changing its module path, or its instance key — reads to Terraform as one resource destroyed and a different one created. Doing that deliberately without destroying anything is what moved blocks are for; see Import & Refactoring.

The object storage resource shape — a real divergence

Providers disagree about granularity, and object storage is the clearest example. The same intent — "a bucket with versioning and public access blocked" — is a different number of resources on each cloud. Azure additionally needs its resource group and a separate container.

Comparison showing the same object storage intent taking a different number of resources on each of the three clouds

```hcl
resource "aws_s3_bucket" "demo" {
  bucket = "tf-resources-demo-0725"
}

# Features are separate resources, each referencing the bucket.
resource "aws_s3_bucket_versioning" "demo" {
  bucket = aws_s3_bucket.demo.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_public_access_block" "demo" {
  bucket = aws_s3_bucket.demo.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
```
Three resources for one bucket. The AWS provider deliberately split bucket
features out of the `aws_s3_bucket` resource, which means more blocks, more
references, and a wide fan-out in the graph — but also that each feature can be
managed and reviewed independently.
```hcl
resource "azurerm_resource_group" "demo" {
  name     = "tf-resources-demo"
  location = "westeurope"
}

resource "azurerm_storage_account" "demo" {
  name                     = "tfresourcesdemo0725"
  resource_group_name      = azurerm_resource_group.demo.name
  location                 = azurerm_resource_group.demo.location
  account_tier             = "Standard"
  account_replication_type = "LRS"

  # Features are nested blocks on the account.
  blob_properties {
    versioning_enabled = true
  }
}

# A container is a separate object, not a feature.
resource "azurerm_storage_container" "demo" {
  name                  = "data"
  storage_account_name   = azurerm_storage_account.demo.name
  container_access_type  = "private"
}
```
Three resources too, but for a different reason: the resource group and the
container are genuinely separate *objects* rather than features of one. The
closest equivalent to "a bucket" is the storage account plus a container, which
is the mapping to keep in mind when reading the rest of this article.
```hcl
resource "google_storage_bucket" "demo" {
  name     = "tf-resources-demo-0725"
  location = "EUROPE-WEST1"

  # Features are nested blocks on the bucket.
  versioning {
    enabled = true
  }

  public_access_prevention = "enforced"
}
```
One resource. GCP's provider keeps features as nested blocks and arguments, so
the configuration is shortest and the graph flattest — at the cost of a single
resource whose diff covers everything, so any change touches one node.

The consequence worth carrying forward: "how many resources is a bucket" has three different answers, and it changes the shape of your configuration, your graph and your review diffs. It's also why the AWS examples in this article have more blocks than the GCP ones without doing more. Canonical comparison in Providers & the Registry.

Data sources

# Read something this configuration does not manage.
data "aws_caller_identity" "current" {}

data "aws_s3_bucket" "existing_logs" {
  bucket = "central-logging-bucket-created-by-another-team"
}

resource "aws_s3_bucket_policy" "app" {
  bucket = aws_s3_bucket.app.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { AWS = data.aws_caller_identity.current.account_id }
      Action    = "s3:PutObject"
      Resource  = "${data.aws_s3_bucket.existing_logs.arn}/*"
    }]
  })
}

Data sources are read during planning, when their arguments are known — which is what makes their values usable to compute other things. A data source whose arguments depend on a resource that doesn't exist yet can't be read then, so it's deferred to apply and its values are unknown during planning.

Two failure modes are worth knowing before you rely on one. A data source that finds nothing is an error, at plan time, so a missing lookup target blocks every operation including destroy — which is an unpleasant surprise when tearing down an environment whose dependency has already gone. And data sources make plans depend on the outside world, so a plan is only as reproducible as the things it reads.

Looking up existing infrastructure

```hcl
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

data "aws_s3_bucket" "existing" {
  bucket = var.existing_bucket_name    # by globally unique name
}
```
Lookups are by name or by tag filter, scoped to the provider's account and
region. `aws_caller_identity` and `aws_region` are the two most-used data
sources in existence and are worth knowing by heart.
```hcl
data "azurerm_client_config" "current" {}

data "azurerm_storage_account" "existing" {
  name                = var.existing_account_name
  resource_group_name = var.existing_resource_group   # ← extra argument
}
```
Azure lookups almost always need the resource group as well as the name,
because the name alone doesn't identify the object within a subscription. That
extra required argument is the most common reason an Azure data source fails
where the AWS equivalent worked.
```hcl
data "google_client_config" "current" {}

data "google_storage_bucket" "existing" {
  name = var.existing_bucket_name      # by globally unique name
}
```
Bucket names are global, so a name suffices — but many other GCP data sources
need `project` explicitly when it differs from the provider's default, which is
the GCP equivalent of Azure's resource-group requirement.

Getting Started

Two resources, one reference, one data source — and the plan output is the lesson.

# main.tf
terraform {
  required_version = ">= 1.5"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}

provider "aws" {
  region = "eu-west-1"
}

data "aws_caller_identity" "current" {}

resource "aws_s3_bucket" "demo" {
  bucket = "tf-resources-demo-0725"
}

resource "aws_s3_bucket_versioning" "demo" {
  bucket = aws_s3_bucket.demo.id

  versioning_configuration {
    status = "Enabled"
  }
}

output "bucket_arn" {
  value = aws_s3_bucket.demo.arn
}

output "account_id" {
  value = data.aws_caller_identity.current.account_id
}

1 · Plan, and read the unknowns

terraform init && terraform plan
# UNVERIFIED — confirm against a real run
data.aws_caller_identity.current: Reading...
data.aws_caller_identity.current: Read complete after 0s [id=123456789012]

Terraform will perform the following actions:

  # aws_s3_bucket.demo will be created
  + resource "aws_s3_bucket" "demo" {
      + arn    = (known after apply)
      + bucket = "tf-resources-demo-0725"
      + id     = (known after apply)
    }

  # aws_s3_bucket_versioning.demo will be created
  + resource "aws_s3_bucket_versioning" "demo" {
      + bucket = (known after apply)
      + id     = (known after apply)

      + versioning_configuration {
          + status = "Enabled"
        }
    }

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

Changes to Outputs:
  + account_id = "123456789012"
  + bucket_arn = (known after apply)

Three things to notice. The data source was read during planning, before anything was created, and its value is concrete — so account_id shows a real value while bucket_arn doesn't. The versioning resource's bucket is (known after apply) because it references a bucket that doesn't exist yet. And status = "Enabled" is known, because you wrote it.

2 · Apply, then inspect by address

terraform apply
terraform state list
# UNVERIFIED — confirm against a real run
data.aws_caller_identity.current
aws_s3_bucket.demo
aws_s3_bucket_versioning.demo

The data source is in state — as a cached read, not as something Terraform owns.

terraform state show aws_s3_bucket.demo

Every attribute the provider returned, including the many you didn't set. This is the fastest way to discover what's available to reference.

3 · Provoke the errors worth recognising

Try setting a computed attribute — add arn = "anything" to the bucket:

# UNVERIFIED — confirm against a real run
Error: Value for unconfigurable attribute
  Can't configure a value for "arn".

Try referencing an attribute that doesn't exist — aws_s3_bucket.demo.nonexistent:

# UNVERIFIED — confirm against a real run
Error: Unsupported attribute

  This object has no argument, nested block, or exported attribute named
  "nonexistent".

Try a data source that finds nothing:

data "aws_s3_bucket" "missing" {
  bucket = "this-bucket-definitely-does-not-exist-0725"
}
# UNVERIFIED — confirm against a real run
Error: Failed getting S3 bucket

  NotFound: Not Found

No plan is produced at all. That's the failure mode to remember: a broken data source blocks every operation, not just the resource that uses it.

4 · Rename, and watch the address matter

Change resource "aws_s3_bucket" "demo" to "app_data" — and update the reference — then plan:

# UNVERIFIED — confirm against a real run
Plan: 2 to add, 0 to change, 2 to destroy.

You changed a name that exists only in your configuration, and Terraform proposes to destroy and recreate real infrastructure, because the address is the identity. Revert it rather than applying; the tool for doing this deliberately is a moved block (Import & Refactoring).

terraform destroy

In Practice

Reference, never duplicate. The rule the whole page builds to. If a value exists as an attribute of something in the same configuration, reference it — for correctness, and for the dependency edge. If it exists in another configuration, read it through a data source. Only genuinely external constants belong as literals, and those belong in variables anyway.

Name resources for their role, not their type. The type is already in the address:

resource "aws_s3_bucket" "app_data" { }   # good: aws_s3_bucket.app_data
resource "aws_s3_bucket" "this" { }       # good in a module with one bucket
resource "aws_s3_bucket" "s3_bucket" { }  # bad: aws_s3_bucket.s3_bucket.id

this is the established convention for the single principal resource in a module, and it reads well at the call site because callers see the module name instead.

Prefer explicit references over data-source lookups within your own estate. A data source that looks up a resource you manage — even in another state — is weaker than it looks: it can return stale values, it fails at plan time if the target is missing, and it hides the relationship from anyone reading the configuration. Where the dependency crosses a state boundary, the options and their trade-offs are Repo & Environment Structure.

Set the arguments that matter, even when the default is right. Provider defaults change between versions, and an omitted argument is an argument you haven't decided. For anything security-relevant — public access, encryption, versioning — write it explicitly, so the configuration states the intent and a provider upgrade can't quietly change it.

Avoid terraform_data and null_resource with provisioners. The pattern looks like a pragmatic escape hatch and it has three specific defects: it runs only at creation, so it never re-runs when the script changes; it's invisible to plan, so reviewers can't see what it will do; and a failure taints the resource so the next apply replaces it. If something must happen imperatively, run it outside Terraform — in the pipeline, before or after the apply — where it can be logged, retried and reviewed.

What a reviewer should look for in a diff touching this topic:

  • A hardcoded ARN, ID, name or endpoint that names something in the same configuration. The single highest-value comment. Ask for the reference.
  • A reference that was replaced by a literal. Silently removes a dependency edge, and no tooling flags it. Easy to miss and the most damaging item on this list.
  • A renamed resource without a moved block. Check the plan for destroys.
  • A new data source. What happens to plan and destroy if the target doesn't exist? Should this be a variable, or a reference to a resource you already manage?
  • Omitted security-relevant arguments — public access, encryption, versioning left to provider defaults.
  • terraform_data or null_resource, especially with a provisioner. Ask what it's for and whether the pipeline should do it.
  • Resource names repeating the type, and names that are numbered rather than meaningful.

Blast radius and rollback. The dangerous edit on this page is renaming — a change to something that exists only in your configuration, which proposes to destroy real infrastructure. Removing a resource block is equally consequential and at least looks like it: it means destroy. Data sources are safe to add and remove, since they own nothing, with the one exception that adding one whose target may not exist can block destroy later.


Ecosystem

The provider registry documentation. Per resource type: the argument reference, the attribute reference, and an import section. This is where you find out whether something is settable or computed, and whether changing it forces replacement — questions the language can't answer for you.

terraform state show ADDRESS. Prints every recorded attribute of one resource, which is the fastest way to discover what's available to reference. Faster than the documentation when you already have the thing applied.

terraform console. Evaluate a reference against real state before committing to it — terraform console then aws_s3_bucket.demo.arn. Removes an entire class of guesswork.

terraform providers schema -json. The complete machine-readable schema, including which attributes are computed and which force replacement. Overkill for daily use, and the right tool when you need to answer that question programmatically or in bulk. ⚠️ verify subcommand name.

terraform-docs. Generates input/output documentation from configuration, which matters most for modules but keeps any repository's README honest about what it exposes. Modules.

Provider-specific note. Resource granularity is the divergence that shows up on every page of this article: AWS splits object storage features into separate resources, Azure separates the account from the container and requires a resource group, GCP nests features into one resource. Data source lookups diverge too — Azure lookups generally need a resource group, GCP lookups often need a project, AWS lookups are scoped by the provider's account and region.


Production

Security

Two things on this page are security controls. Explicit arguments over provider defaults for public access, encryption and versioning — because a default is a decision made by someone else that can change in a minor version, and the plan that reveals it will be attached to an unrelated change. References over hardcoded identifiers for policy documents, because a stale hardcoded ARN in a policy either fails closed (an outage) or, worse, matches something else. The jsonencode-over-references pattern earlier on this page is the safe form of both.

Blast radius

Renaming is the trap: an edit to a purely local label proposes destroying real objects, and it looks harmless in a diff. Removing a block is the other, and it at least reads as what it is. The mitigations are procedural — read the plan's destroy count, use moved blocks for renames — plus structural, in that prevent_destroy on data-bearing resources turns the mistake into an error (Meta-Arguments).

Scale

Resource granularity drives state size, and the provider chooses it: a bucket configured "properly" is one state entry on GCP and three or four on AWS, so an equivalent AWS estate has a larger state and a slower refresh for identical infrastructure. Data sources add plan-time cost too — each is at least one API call on every plan, and a configuration with many lookups spends real time before the refresh phase even starts. Scale & Performance.

Team workflow

The convention worth writing down is the naming one — role-based names, this for a module's principal resource, no type names in resource names — because it's invisible to tooling and permanent once a repository has grown. The review habit worth building is looking for literals that should be references, which is the defect that no linter catches and that surfaces months later in an unrelated environment.

Reliability

References are a reliability feature: they cannot be stale, and they carry ordering. Hardcoded identifiers are the opposite, and they fail intermittently rather than immediately, which is the worst available failure mode. Data sources are a reliability liability in one specific way — a missing target is a plan-time error, so a dependency that disappears blocks even destroy, and the resulting environment can't be torn down without editing the configuration. Worth knowing before you meet it at the end of a project.


Interview Questions

Conceptual

What's the difference between an argument and an attribute?

Direction. An argument is a value you set in the configuration; an attribute is a value you can read from the object. Many are both — you set bucket and can read it back. Some are computed only: an ARN, a generated ID, an endpoint. Trying to set one is an error, because the provider decides it.

The practical use of the distinction: the registry documentation splits every resource into an argument reference and an attribute reference, so "can I set this?" is answered by which list it's in.

What's the difference between a resource and a data source?

A resource is managed — Terraform creates it, records it in state, updates it and destroys it when the block is removed. A data source only reads: it queries the provider for information about something Terraform doesn't manage and exposes it as attributes. State marks entries managed or data, and removing a data source destroys nothing.

The operational difference worth mentioning: data sources are read during planning, which is what makes their values usable to compute other things — and also means a data source that finds nothing is a plan-time error that blocks every operation, including destroy.

What does a resource name do, and where does it appear?

Nothing in the cloud. It's the second string in the block header, it's local to the configuration, it must be unique within its type, and it exists so you can reference the resource and so state can identify it. The real-world name comes from an argument — bucket, name — and the two are unrelated and can differ.

Because the name is part of the resource address, and the address is the identity in state, renaming reads to Terraform as destroying one resource and creating a different one. That's why renames need moved blocks.

What creates a dependency between two resources?

A reference. Writing aws_s3_bucket.logs.id inside another resource both supplies the value and creates an edge, so the bucket is created first. Nothing else about the configuration — file order, adjacency, being in the same module — creates ordering.

The corollary is the important part: replacing that reference with a hardcoded string still applies correctly most of the time, having silently deleted the edge. The failure then appears on a fresh apply into a new environment. Detail in The Dependency Graph.

Why is `aws_s3_bucket.s3_bucket` a bad name?

Because the type is already in the address, so every reference reads aws_s3_bucket.s3_bucket.id — the same word twice, conveying nothing. Names should describe the resource's role: app_data, logs, artefacts. The convention for a module's single principal resource is this, which reads well at the call site because callers see the module name instead.

It's a small thing that's expensive to fix later, because the name is part of the address and renaming means either a moved block or destroying infrastructure.

Technical depth

Explain the resource address format.

[module.NAME[["key"]].]TYPE.NAME[["key"]|[index]], with data. prefixed for data sources. So aws_s3_bucket.demo, aws_s3_bucket.demo["logs"], module.storage["eu"].aws_s3_bucket.demo, data.aws_caller_identity.current.

It's worth learning because it's the only language Terraform speaks about resources: plan output, state entries, error messages, and the arguments to state mv, import, -replace= and -target. An error naming a module-nested keyed instance is fully legible once you can parse it, and opaque otherwise.

It's also the identity in state, which is why any change to it — name, module path, or instance key — means destroy-and-create unless a moved block says otherwise.

Why is a value `(known after apply)` even though I wrote it in the configuration?

Because what you wrote contains a reference to something that doesn't exist yet. An expression containing an unknown value is itself unknown, so a policy document you fully specified renders as unknown if it interpolates a not-yet-created bucket's ARN.

Ordinarily this is cosmetic. It matters when an unknown lands on an attribute that forces replacement, because Terraform can't prove the value won't change and must plan for replacement — which can propose destroying a healthy resource. The mitigation is to derive names and identifiers from variables and locals, which are known at plan time, rather than from other resources' computed attributes. See The Plan/Apply Lifecycle.

When is a data source the wrong tool?

Four cases. When it looks up something the same configuration manages — reference the resource directly; the data source is slower, can create a cycle, and can return stale values. When a variable would be clearer, such as looking up the current region to build a name, which makes the plan depend on ambient context. When the target may not exist, because a failed lookup is a plan-time error that blocks everything including destroy. And when it's being used to couple two states loosely, where the coupling deserves an explicit decision rather than an incidental lookup.

Data sources are right for genuinely external things: another team's resources, provider metadata like account identity, or AMI and image lookups.

What happens if a data source's arguments depend on a resource that doesn't exist yet?

It can't be read during planning, so it's deferred to apply and its attributes are unknown at plan time — rendered <= in plan output. Everything derived from it becomes unknown too, which is how one deferred data source can make a large part of a plan vague.

This is a signal worth heeding rather than working around: it usually means the configuration is trying to do two sequential things in one apply, and splitting it — or referencing the resource directly instead of looking it up — is the better answer.

Why avoid `null_resource` / `terraform_data` with provisioners?

Three concrete defects. It runs only at creation, so it never re-runs when the script changes — the configuration it applies can't converge. It's invisible to plan, so a reviewer can't see what will happen. And a failed provisioner taints the resource, so the next apply replaces the whole thing.

Net effect: you've added an imperative step that Terraform can neither predict, verify nor reconcile, inside a tool whose entire value is predicting, verifying and reconciling. Alternatives: pre-baked images, cloud-init/user-data, or a pipeline step before or after the apply where it can be logged and retried.

How does this differ across AWS, Azure and GCP?

The resource block, references and addresses are Terraform's and identical. What differs is granularity — how many resources one logical thing takes.

AWS splits object storage features into separate resources: the bucket, then aws_s3_bucket_versioning, aws_s3_bucket_public_access_block, and so on, each referencing the bucket. So three or four blocks for one bucket, a wide graph, and each feature independently reviewable.

Azure separates genuinely different objects: a resource group, a storage account, and a container. The closest thing to "a bucket" is an account plus a container, and every resource needs the group.

GCP keeps features as nested blocks on one resource, so it's one block, one state entry, one node — and one diff covering everything.

Data source lookups diverge in the same spirit: Azure lookups generally need resource_group_name as well as the name because the name alone doesn't identify the object; GCP lookups often need project when it differs from the provider default; AWS lookups are scoped by the provider's account and region. Canonical comparison in Providers & the Registry.

Scenario and design

A colleague's PR replaces `aws_s3_bucket.logs.arn` with the literal ARN string. What do you say?

Ask for the reference back, and explain that the problem isn't style. Two things break. The value can go stale — if the bucket is ever recreated, or the account or region differs, the literal is silently wrong. And more importantly the dependency edge disappears: Terraform no longer knows the bucket must exist first, so the two resources are created concurrently and the apply fails intermittently, typically on a fresh apply into a new environment rather than here.

That second failure is the one to emphasise, because it passes review and passes in dev, then fails during a disaster-recovery test or a new region rollout, months later, looking unrelated.

If the reference genuinely can't be used because the bucket lives in another state, the answer is a data source or a remote state lookup — not a literal.

Design the configuration for "a bucket with versioning, encryption and no public access" in a way that reviews well.

Explicit arguments for every security-relevant setting rather than relying on provider defaults, so the configuration states the intent and a provider upgrade can't quietly change it. Name the resource for its role. Reference the bucket from each feature resource rather than repeating its name. Build any policy document with jsonencode over an object rather than a heredoc.

On AWS that's three or four resources — bucket, versioning, public access block, encryption — and the review benefit is that each is a separate, legible diff. On GCP it's one resource with nested blocks and a public_access_prevention argument. On Azure it's a resource group, a storage account with blob_properties, and a container.

The cross-cloud point worth raising in a review: because the three providers differ in granularity, a "one module per cloud" abstraction is usually more honest than one module pretending they're the same shape.

Your `terraform destroy` fails with a data source error. What's happening and how do you get out?

A data source's target has already been deleted, so the lookup fails at plan time — and because no plan can be produced, destroy can't run either. Common when tearing down an environment whose shared dependency was removed first, or when environments are destroyed in the wrong order.

Ways out, roughly in order of preference: recreate or restore the missing target temporarily so the lookup succeeds, then destroy properly. Or remove the data source and whatever references it from the configuration, then destroy. Or, as a last resort, state rm the affected resources and clean up manually, accepting that you're now doing it by hand.

The design lesson is to prefer explicit inputs over lookups for cross-environment dependencies, since a variable can't fail to resolve — and to make from-empty create and destroy a routine exercise, because that's what surfaces this before it matters.

You've inherited a configuration where every resource is named `main`, `main2`, `main3`. Worth fixing?

Yes, and not in one pull request, because every rename changes an address and therefore state.

The mechanism is moved blocks: add one per rename mapping the old address to the new, plan, and confirm it reports no infrastructure changes — that result is the proof the mapping is right, since any mistake shows as a destroy. Apply, then delete the blocks in a later commit.

Sequencing: do it in small batches by domain rather than all at once, so a surprising plan is easy to attribute, and do it when nothing else is in flight, since the diffs touch every reference. Start with the least-critical resources to build confidence in the process.

Worth being honest that this is cosmetic work with real risk, so it's justified by how long the repository will live and how many people read it — on something being decommissioned next quarter, leave it alone.


Commands & Gotchas

terraform state list                              # every address in state
terraform state show aws_s3_bucket.demo           # all recorded attributes — what you can reference
terraform console                                 # then: aws_s3_bucket.demo.arn
terraform providers schema -json | jq .            # which attributes are computed, machine-readable
terraform plan -target=aws_s3_bucket.demo         # recovery only — addresses are the argument
terraform apply -replace=aws_s3_bucket.demo       # replace one resource deliberately
terraform state mv aws_s3_bucket.old aws_s3_bucket.new   # imperative rename; prefer moved blocks
terraform output -raw bucket_arn                  # one output, unquoted
Behaviour Why it matters
The resource name exists only in configuration and state Never appears in the cloud. The real name comes from an argument
The address is the identity in state Renaming proposes destroy-and-create. Use moved blocks
A reference supplies a value and creates an edge Replacing one with a literal silently removes the dependency
Computed attributes can't be set arn, id, endpoints. The error is "unconfigurable attribute"
(known after apply) spreads Anything derived from an unknown is unknown, including values you wrote fully
Data sources are read at plan time when arguments are known Which is why their values can compute other things
A data source that finds nothing is a plan-time error It blocks everything, including destroy
A data source depending on a new resource is deferred Rendered <=, and its values are unknown during planning
Removing a resource block means destroy Removing a data block means nothing
Omitted optional arguments use provider defaults Which can change between provider versions. Set security-relevant ones explicitly
Resource granularity is a provider decision One bucket is 1 resource on GCP, 3–4 on AWS, and account-plus-container on Azure
id contents vary wildly by provider A name on S3, a full path on Azure. Don't assume it's what you want

← Back to The Language · Next: Variables, Locals & Outputs →


⚠️ Verification checklist (delete before publishing)

Command and error output

  • The full plan output in Getting Started, especially: that the data source Read lines appear before the plan, that account_id shows a concrete value while bucket_arn shows (known after apply), and which bucket attributes appear as unknown.
  • Value for unconfigurable attribute error text.
  • Unsupported attribute error text.
  • The data-source-not-found error text, and confirm it genuinely blocks plan entirely — this claim carries an interview answer and a Production paragraph.
  • Confirm a failed data source also blocks destroy. The scenario question depends on it entirely. If destroy actually succeeds, that answer must be rewritten.
  • The rename plan showing 2 to add … 2 to destroy, and that the count is right for this config.
  • The module-nested error example's exact layout — the with module.storage["eu"]… line format.
  • terraform providers schema -json subcommand name (flagged inline; same item as the providers page).

Resource and attribute claims

  • That aws_s3_bucket exposes bucket_domain_name and that force_destroy defaults to false.
  • That aws_s3_bucket's id is the bucket name and Azure resource id values are full paths.
  • aws_s3_bucket_public_access_block argument names, all four.
  • GCP public_access_prevention = "enforced" — valid value and argument name.
  • Azure blob_properties { versioning_enabled } nesting on azurerm_storage_account.
  • azurerm_storage_container's storage_account_name — ⚠️ may be storage_account_id in azurerm 4.x. Same item as the dependency-graph page; fix both together.
  • data "azurerm_storage_account" requiring resource_group_name — the tab set makes a general claim about Azure lookups from this one example. Verify it generalises.
  • data "google_client_config" and data "azurerm_client_config" names.
  • terraform_data minimum version and that it supersedes null_resource (flagged inline).
  • That a failed provisioner taints the resource — same item as 00-orientation/01-why-iac-exists.md.

Versions

  • ~> 5.0 aws — ninth page. The sweep should now be one task across the whole docs/ tree.

Structure

  • Two <Tabs> blocks on this page, where every other page has one: resource granularity, and data source lookups. Both are genuine divergences and both are on-topic, but confirm two doesn't feel heavy — if it does, the data source set is the one to compress into prose plus a single AWS block.
  • Length: check rendered length. This page has two tab sets and fifteen <details>, so raw source overstates it more than usual.
  • All relative links resolve.