OnCallReady

Lesson 12.18 · Terraform: Language & Workflow · 21 min read

Data sources, references and the dependency graph

In plain words

When you plan a birthday party, some things you bring yourself (the cake, the balloons) and some things you just look up (the address of the park, what time it opens). You are in charge of the cake: you can make it, change it, throw it away. The park you only check on; you would never bulldoze it after the party.

In Terraform, a resource is the cake: created, changed and destroyed by you. A data source is the park: read on every plan, never changed. And Terraform plans the order of the party from references: when one block uses another's value (azurerm_resource_group.main.name), it knows to do that one first. depends_on is for the rare "you must do this first" that no reference shows.

Why this matters

What you need to know already: resource blocks and references (12.1), outputs (12.5), expressions (12.11), dependencies between systemd units (2.14 - "ordering is not dependency" has a Terraform twin here).

Your configuration rarely lives alone. The shared network was built by another team; the account you run as already exists. You need their IDs without taking ownership - and Terraform must create your own things in the right order. This lesson covers both: data sources (read-only lookups) and the dependency graph.

resource vs data, precisely

resource "azurerm_resource_group" "orders" {     # Terraform OWNS this
  name     = "rg-orders-dev"
  location = "westeurope"
}

data "azurerm_resource_group" "shared" {          # Terraform READS this
  name = "rg-shared-network"
}

A resource is created, updated and destroyed by this configuration. It is in state as an object Terraform manages, and it appears in destroy plans.

A data source is a query. Terraform asks the provider "what does this look like right now?" on every plan, uses the answer, and forgets it. It never creates, changes or deletes the target. terraform destroy shows only resources.

The two share a type name - azurerm_resource_group exists as both - but the data source takes fewer arguments (the ones that identify the object) and exposes the rest as attributes:

output "shared_location" {
  value = data.azurerm_resource_group.shared.location
}

Note the address: data sources are always prefixed with data..

When to use a data source

The one every Azure configuration has:

data "azurerm_client_config" "current" {}

resource "azurerm_key_vault" "main" {
  name                = "kv-orders-dev"
  location            = azurerm_resource_group.orders.location
  resource_group_name = azurerm_resource_group.orders.name
  tenant_id           = data.azurerm_client_config.current.tenant_id
  sku_name            = "standard"
}

azurerm_client_config needs no arguments; it returns tenant_id, subscription_id, client_id and object_id of whoever is running Terraform - which is how you grant the CI job's own identity access to the Key Vault (secret store, 12.3) it just created. sku_name is the vault's price tier.

Looking up something that does not exist is a plan-time error, which is exactly what you want - "the hub VNet is not where we think it is" should stop the run, not create a new one.

data vs import, the question people get wrong

If you want Terraform to take over an existing resource - manage it from now on, change it, eventually delete it - that is import, not a data source. Importing gives you a resource block you own. A data source gives you read-only attributes of something you will never touch. Ch 13 does import.

When data sources are read

Normally during plan, before anything is created. (azurerm_subnet below looks up a subnet - an address range inside a VNet, 12.8.) But if a data source's arguments depend on something that does not exist yet, it cannot be read until apply:

data "azurerm_subnet" "app" {
  name                 = "snet-app"
  virtual_network_name = azurerm_virtual_network.main.name   # being created in this run
  resource_group_name  = azurerm_resource_group.orders.name
}

The plan shows it being deferred:

  # data.azurerm_subnet.app will be read during apply
  # (config refers to values not yet known)
 <= data "azurerm_subnet" "app" {
      + id = (known after apply)
      ...
    }

How to read this plan fragment: # lines are Terraform's comment on what will happen; <= is the "read" symbol (you will meet + create, ~ update, - destroy in 12.24); (known after apply) means the value does not exist yet. Everything computed from it is (known after apply) too. Reading a data source about something you create in the same configuration is usually a smell: reference the resource directly (azurerm_subnet.app.id) instead.

References build the graph

Terraform never runs your blocks in file order. It builds a dependency graph

things in parallel (10 at a time by default; the flag -parallelism=N on plan or apply changes it).

resource "azurerm_resource_group" "main" {
  name     = "rg-orders-dev"
  location = "westeurope"
}

resource "azurerm_virtual_network" "main" {
  name                = "vnet-orders-dev"
  location            = azurerm_resource_group.main.location   # a reference
  resource_group_name = azurerm_resource_group.main.name       # another one
  address_space       = ["10.20.0.0/16"]
}

resource "azurerm_storage_account" "logs" {
  name                     = "stordersdevlogs"
  resource_group_name      = "rg-orders-dev"                   # a string, not a reference!
  location                 = "westeurope"
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

The VNet implicitly depends on the resource group because it references it. The storage account does not - it names the resource group with a literal string, so Terraform may try to create it at the same time as the group, and on a fresh environment it fails with ResourceGroupNotFound. The fix is not depends_on; it is referencing the resource (azurerm_resource_group.main.name). References carry the dependency and the value at once.

You can see the graph. terraform graph prints it:

$ terraform graph
digraph G {
  rankdir = "RL";
  node [shape = rect, fontname = "sans-serif"];
  "azurerm_resource_group.main" [label="azurerm_resource_group.main"];
  "azurerm_storage_account.logs" [label="azurerm_storage_account.logs"];
  "azurerm_virtual_network.main" [label="azurerm_virtual_network.main"];
  "azurerm_virtual_network.main" -> "azurerm_resource_group.main";
}

The output is DOT, a text format for drawings: each "A" -> "B" line is an arrow "A depends on B". Paste it into any Graphviz viewer to see boxes and arrows. The storage account floating on its own, with no arrow, is the bug above made visible.

Destroy walks the same graph backwards: the VNet goes before the resource group, because it depends on it.

depends_on: only for dependencies Terraform cannot see

A role assignment is Azure's permission grant: "this identity may do this role's actions on this thing" - like adding a user to a group (4.3), for the cloud. Here, the identity running Terraform gets permission to write secrets into the vault:

resource "azurerm_role_assignment" "pipeline_kv" {
  scope                = azurerm_key_vault.main.id
  role_definition_name = "Key Vault Secrets Officer"
  principal_id         = data.azurerm_client_config.current.object_id
}

resource "azurerm_key_vault_secret" "db" {
  name         = "db-password"
  value        = random_password.db.result
  key_vault_id = azurerm_key_vault.main.id

  depends_on = [azurerm_role_assignment.pipeline_kv]
}

The secret references the vault, but nothing in it references the role assignment - yet writing the secret fails with 403 (HTTP "forbidden", 9.21) until the role exists. That is a hidden dependency: it lives in Azure's authorisation model, not in any attribute. depends_on states it explicitly.

Rules for depends_on:

Meta-arguments

Arguments that every resource accepts, because Terraform - not the provider - handles them:

depends_on     explicit dependencies
count          N instances, addressed [0], [1], ...
for_each       one instance per map key / set element, addressed ["key"]
provider       which provider configuration: provider = azurerm.dr
lifecycle      create_before_destroy, prevent_destroy, ignore_changes,
               replace_triggered_by, precondition, postcondition

(count and for_each are 12.20; lifecycle is Ch 13.) Modules accept count, for_each, depends_on and providers. That is the complete list; anything else in a block is an argument for the provider.

Addresses, precisely

An address names one thing in your configuration. You will type addresses into terraform state, -target, -replace, import and moved blocks constantly (12.24 and Ch 13):

azurerm_subnet.app                              a resource (all instances)
azurerm_subnet.app[0]                           one instance (count)
azurerm_subnet.app["web"]                       one instance (for_each)
data.azurerm_client_config.current              a data source
module.network                                  a module call
module.network.azurerm_subnet.app["web"]        a resource inside it
module.env["prod"].module.network.azurerm_subnet.app["web"]    nested, with for_each

In a shell, quote addresses that contain brackets and double quotes:

terraform state show 'azurerm_subnet.app["web"]'
terraform apply -replace='azurerm_linux_virtual_machine.web[1]'

(terraform state show ADDR prints what state holds for one resource; -replace=ADDR forces one resource to be rebuilt. Both come back in 12.24.) Without the single quotes, bash eats the double quotes (6.6) and zsh tries to glob the brackets.

What you can now do:

Why it helps

A classic first-environment failure: a storage account fails with ResourceGroupNotFound, because someone wrote the resource group name as a literal string instead of a reference, so there was no edge in the graph and Terraform created both in parallel. terraform graph shows the storage account floating alone. In review you will also catch the opposite: depends_on on a data source, which makes every plan show (known after apply). And the Key Vault 403 on first apply, where the secret needs the role assignment first, is the textbook hidden dependency. Data vs import is a standard interview trap.

Commands in this lesson

terraform

FAQ

When should I use a data source instead of a resource?

Use a data source for something you need to read but must not manage: the central network another team owns, a shared DNS zone, the current subscription and identity via azurerm_client_config. If you want Terraform to own the object from now on, including changing and eventually deleting it, write a resource and import it instead.

Why does my data source show "will be read during apply"?

Its arguments depend on a value that is not known yet, usually a resource being created in the same run, or it has a depends_on. Terraform cannot query it at plan time, so it defers the read (the <= symbol) and everything computed from it becomes (known after apply). Reading a data source about something you create in the same configuration is usually a smell; reference the resource directly.

When do I actually need depends_on?

Only for dependencies Terraform cannot see from references. The classic one: writing a Key Vault secret needs a role assignment on the vault to exist, but the secret block never references the role assignment, so without depends_on the write can hit a 403. Don't use it to control order for its own sake; on modules it serialises everything inside the module behind everything in the dependency.

Does Terraform run my resources in the order I write them?

No. It builds a dependency graph from references and walks it, running independent operations in parallel, 10 at a time by default (-parallelism=N). Destroy walks the same graph backwards, so a VNet is deleted before its resource group. terraform graph prints the graph in DOT format; a resource with no edges when you expected one is often a literal string that should have been a reference.

Why do I need quotes around addresses in the shell?

Addresses like azurerm_subnet.app["web"] contain brackets and double quotes. Bash removes the double quotes and zsh tries to glob the brackets, so the command fails or targets the wrong thing. Wrap them in single quotes: terraform state show 'azurerm_subnet.app["web"]'. You will type addresses constantly in state, -target, -replace, import and moved.

In an interview Junior

How does Terraform decide the order in which it creates resources?

From references, not from file order. When azurerm_virtual_network.main uses azurerm_resource_group.main.name, it implicitly depends on the group. Terraform builds a dependency graph from every reference, then walks it - independent resources in parallel (10 at a time by default), dependents after what they need. Destroy walks the same graph backwards. terraform graph prints it.

The classic bug: a resource names its resource group with a literal string instead of a reference, so it has no edge in the graph and fails on a fresh environment with ResourceGroupNotFound. The fix is the reference, not depends_on.

depends_on is only for hidden dependencies that no attribute shows - for example a secret that can only be written once a role assignment exists. Overusing it slows plans and, on a data source, defers the read to apply.

A data source is read-only: it looks up something that exists, Terraform never creates or deletes it.

Also asked: What is the difference between a data source and terraform import? · When would you use depends_on? · What are meta-arguments in Terraform?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.