Dependency Graph
Terraform never asks you what order to do things in, and it ignores the order you wrote them in. It derives the order from a graph it builds out of your references. This page is what puts an edge in that graph, what therefore runs in parallel and what queues behind something else, and how to walk the graph backwards from an ordering surprise to the line of configuration that caused it.
Prerequisites: The Plan/Apply Lifecycle
What & Why
The dependency graph is a directed acyclic graph Terraform constructs from your configuration, where nodes are things that must be created, read or destroyed, and an edge from A to B means A must complete before B starts. Terraform walks it to plan, and walks it again to apply.
The bad practice it replaces
Ordered scripts, and the ordering bugs that come with them. In a shell script, order is whatever you typed, which means correctness depends on the author remembering that the subnet needs the network and the policy needs the bucket — and it means the script is serial even where it needn't be, because expressing "these six things can happen at once, and then this seventh thing" is genuinely awkward in bash and nobody does it.
Terraform's answer is that you don't declare order at all. You declare relationships, by referring to one resource from another, and the order falls out. This is why file ordering is irrelevant and why a resource can reference one declared eighty lines below it. It also means an ordering bug in Terraform is nearly always a missing reference, not a missing instruction — which is a much better class of bug, because the fix makes the configuration more correct rather than more elaborate.
Where it sits
The graph is phase four of the six in The Plan/Apply Lifecycle. That
page said "a graph is built" and moved on; this one is what's in it. What it deliberately leaves out:
how count and for_each expand one configuration block into many graph nodes, and how
create_before_destroy rewrites edges — both are Meta-Arguments, because
both are easier to understand once the plain graph is clear.
Three things it's confused with
It is not the order things appear in your files. Nothing about textual position affects anything. Two resources in the same file with no reference between them are unrelated and will run concurrently.
It is not a graph of resource types. The nodes are instances and configuration objects — providers, variables, locals, outputs and data sources are all in there, not just resources. A great many ordering questions are actually questions about where a provider or a data source sits.
depends_on is not how you express a dependency. It's how you express the rare dependency that
isn't already visible in a reference. If a resource uses another resource's attribute, the edge
already exists; adding depends_on as well is noise, and adding it instead of a reference is worse
than either.
When NOT to reach for it
- Don't use
depends_onto fix an ordering problem you haven't diagnosed. It works often enough to be dangerous. It's coarse — it makes the whole resource wait for the whole other resource, rather than for a specific value — and it's invisible to the reader as an explanation. If ordering is wrong, the first question is which reference is missing. - Don't reach for
-parallelismto make applies faster. The default is usually fine, and the serialisation you're experiencing is almost always the shape of your graph or a provider rate limit, neither of which more concurrency helps. - Don't use
terraform graphto understand a large configuration. Its output is a DOT document that becomes unreadable at about thirty nodes. It's a debugging tool for a specific question, not a documentation tool. See Ecosystem for what to use instead.
Core Concepts
Directed acyclic graph (DAG) — a set of nodes with one-way arrows and no loops. Directed because dependency has a direction; acyclic because a cycle would mean two things each waiting for the other, which has no valid execution order. Terraform rejects cycles outright rather than guessing.
Node — one thing in the graph. Resource instances, data sources, provider configurations, variables, locals, outputs, and module expansion nodes. Not all nodes do work — some exist only to express ordering.
Edge — a "must happen first" relationship. Created by references, depends_on, module
input/output wiring, and provider relationships. Never created by textual proximity.
Implicit dependency — an edge Terraform infers from a reference. Writing
aws_s3_bucket.logs.arn inside another resource creates an edge from the logs bucket to that
resource. The normal and preferred kind.
Explicit dependency — an edge you declare by hand. depends_on = [aws_s3_bucket.logs]. For
relationships real in the cloud but invisible in the configuration — typically IAM permissions that
must exist before an action is attempted.
Hidden dependency — a real-world ordering requirement with no edge at all. The failure this whole topic exists to prevent: a resource that genuinely needs another to exist first, but references it by a hardcoded string, so Terraform sees no relationship and runs them concurrently. Fails intermittently, which is the worst way for anything to fail.
Graph walk — executing the graph. Terraform visits nodes whose dependencies are all satisfied, several at a time, repeating until none remain.
Parallelism — how many nodes are visited concurrently. Ten by default, ⚠️ verify; controlled by
-parallelism=N. It's a cap on concurrency, not a target.
Critical path — the longest chain of dependent nodes. The lower bound on apply duration: no amount of parallelism makes a five-deep chain faster than the sum of its five resources.
Destroy edge — a reversed edge. During destroy, dependencies invert — if the bucket policy depended on the bucket at create time, the bucket must wait for the policy at destroy time. Terraform handles this by reversing the graph, which is why destroy order is create order backwards.
Cycle — a loop, and a hard error. Reported as Error: Cycle: followed by the addresses
involved. Always a configuration problem; never something to work around with -target.
Provider node — the configured provider a resource uses. Every resource depends on its provider being configured. When a provider's own configuration references a resource attribute, that provider node acquires a dependency, and everything using the provider inherits it — the source of the bootstrapping problem in In Practice.
How It Works
What creates an edge
Four sources, and only four.

1 · A reference in an expression. By far the most common, and the one you should want. Any
reference to another resource's attribute — anywhere, including inside a string interpolation, a
for expression, or a nested block — creates an edge:
resource "aws_s3_bucket" "logs" {
bucket = "tf-graph-demo-logs-0725"
}
resource "aws_s3_bucket_policy" "logs" {
bucket = aws_s3_bucket.logs.id # ← this reference is the edge
policy = data.aws_iam_policy_document.logs.json
}
Terraform now knows the bucket must exist before the policy is attached. You wrote no ordering
instruction. Identical in azurerm and google.
2 · depends_on. For dependencies that exist in the cloud but not in your expressions:
resource "aws_s3_bucket" "app" {
bucket = "tf-graph-demo-app-0725"
# The role's policy must exist before this bucket is created, but nothing
# about the bucket's configuration mentions it.
depends_on = [aws_iam_role_policy.bucket_writer]
}
It accepts only static references to whole resources or modules — not attributes, and not expressions — because it's evaluated when the graph is built, before any values exist.
3 · A module boundary. Passing a value into a module creates edges from whatever produced that value to the nodes inside the module that use it; reading a module output creates edges the other way. So module composition is graph composition. Modules.
4 · The provider relationship. Every resource depends on its provider configuration node. This is invisible and free — until the provider's own configuration depends on a resource, at which point everything using that provider waits for it.
What parallelises, and what doesn't
Terraform walks the graph visiting every node whose dependencies are satisfied, up to the parallelism cap. So:

| Shape | Behaviour |
|---|---|
| Resources with no path between them | Concurrent, up to the cap |
| A chain of references | Strictly serial — this is the critical path |
| Many resources depending on one | The one runs first, then the many run concurrently |
| One resource depending on many | The many run concurrently, then the one |
| Same file, no reference | Concurrent. Files mean nothing |
| Same module, no reference | Concurrent. Modules don't serialise their contents |
Two consequences worth holding onto. Apply duration has a floor set by the critical path, not by resource count: a hundred independent buckets is fast, a five-deep chain is not, and no flag changes that. And a widely-referenced resource is a serialisation point — if two hundred resources reference the network, none of them start until it finishes, and the graph is effectively "one thing, then everything".
Destroy runs the graph backwards
This is the part people are surprised by exactly once. Dependency edges reverse for destroy: if the policy depended on the bucket, then at destroy time the bucket depends on the policy being gone first. Terraform reverses the graph rather than asking you to think about it.

The practical implication is that destroy ordering problems are also missing-reference problems,
and they surface later — a destroy that fails because a resource still has a dependent attached
usually means Terraform didn't know about the relationship. The other implication is that
create_before_destroy locally inverts edges, which is how a single lifecycle setting on one
resource can produce a cycle error somewhere else entirely; that's
Meta-Arguments.
Cycles
A cycle is two or more nodes each waiting for the other. Terraform detects it while building the graph and refuses to plan:
# UNVERIFIED — confirm against a real run
Error: Cycle: aws_s3_bucket.a, aws_s3_bucket_policy.a, aws_s3_bucket.b
Three usual causes, in rough order of frequency: mutual references, where two resources each refer
to an attribute of the other — the honest fix is usually a third resource, or moving one side's value
into a local that neither computes from the other; a depends_on pointing back up a chain that
already flows down, often added while debugging an unrelated problem and never removed; and
create_before_destroy interacting with a reference, where the inverted edge closes a loop.
The error names the nodes in the cycle, which is enough to find it by hand in a small configuration.
In a large one, terraform graph filtered to those addresses is faster than reading.
The minimal graph differs by cloud
Terraform's edge rules are identical everywhere. What differs is the shape of the smallest useful graph, because Azure's hierarchy is mandatory: a storage account cannot exist without a resource group, so every Azure configuration has a layer that AWS and GCP configurations don't.
```hcl
# Two nodes, one edge.
resource "aws_s3_bucket" "demo" {
bucket = "tf-graph-demo-0725"
}
resource "aws_s3_bucket_versioning" "demo" {
bucket = aws_s3_bucket.demo.id # edge: bucket → versioning
versioning_configuration {
status = "Enabled"
}
}
```
AWS splits bucket features into separate resources, so a "configure a bucket
properly" configuration has many nodes all depending on the one bucket — a
fan-out from a single serialisation point.
```hcl
# Three nodes, two edges — the resource group is unavoidable.
resource "azurerm_resource_group" "demo" {
name = "tf-graph-demo"
location = "westeurope"
}
resource "azurerm_storage_account" "demo" {
name = "tfgraphdemo0725"
resource_group_name = azurerm_resource_group.demo.name # edge
location = azurerm_resource_group.demo.location
account_tier = "Standard"
account_replication_type = "LRS"
}
resource "azurerm_storage_container" "demo" {
name = "data"
storage_account_name = azurerm_storage_account.demo.name # edge
container_access_type = "private"
}
```
Every Azure graph starts with a resource group, and containers sit below the
storage account — so the critical path is one node longer than the equivalent
AWS or GCP configuration before you've added anything of your own.
```hcl
# Two nodes, one edge.
resource "google_storage_bucket" "demo" {
name = "tf-graph-demo-0725"
location = "EUROPE-WEST1"
versioning {
enabled = true # nested block, not a separate resource
}
}
resource "google_storage_bucket_iam_member" "demo" {
bucket = google_storage_bucket.demo.name # edge
role = "roles/storage.objectViewer"
member = "allAuthenticatedUsers"
}
```
GCP tends to keep configuration in nested blocks rather than separate
resources, so its graphs are typically flatter and wider than AWS's for the
same intent.
The generalisation worth carrying: the same intent produces differently-shaped graphs on different clouds — AWS deep and fanned-out because features are separate resources, Azure one layer deeper because of the resource group, GCP flatter because features are nested blocks. This matters when you compare apply durations across clouds and conclude one provider is slow, when actually one graph has a longer critical path. The canonical scoping comparison is in Providers & the Registry.
Getting Started
Three demonstrations: parallelism, serialisation, and a cycle. Shown in AWS; identical in azurerm
and google except for the graph shapes noted above.
Independent resources run concurrently
# main.tf
terraform {
required_version = ">= 1.5"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" {
region = "eu-west-1"
}
resource "aws_s3_bucket" "one" {
bucket = "tf-graph-demo-one-0725"
}
resource "aws_s3_bucket" "two" {
bucket = "tf-graph-demo-two-0725"
}
resource "aws_s3_bucket" "three" {
bucket = "tf-graph-demo-three-0725"
}
terraform init && terraform apply
# UNVERIFIED — confirm against a real run
aws_s3_bucket.two: Creating...
aws_s3_bucket.one: Creating...
aws_s3_bucket.three: Creating...
aws_s3_bucket.two: Creation complete after 2s [id=tf-graph-demo-two-0725]
aws_s3_bucket.three: Creation complete after 2s [id=tf-graph-demo-three-0725]
aws_s3_bucket.one: Creation complete after 3s [id=tf-graph-demo-one-0725]
All three Creating... lines appear before any completes, and not in the order you wrote them.
That interleaving is the graph walk, and the arbitrary order is the lesson: there is no relationship
between these resources, so Terraform is free to do them in any order, and it does.
Now add a reference
resource "aws_s3_bucket_versioning" "one" {
bucket = aws_s3_bucket.one.id
versioning_configuration {
status = "Enabled"
}
}
terraform apply
# UNVERIFIED — confirm against a real run
aws_s3_bucket_versioning.one: Creating...
aws_s3_bucket_versioning.one: Creation complete after 1s [id=tf-graph-demo-one-0725]
One reference, one edge, and the versioning resource waited for a bucket that already existed. To see it serialise properly, destroy and re-apply everything.
Look at the graph
terraform graph | head -20
# UNVERIFIED — confirm against a real run
digraph {
compound = "true"
newrank = "true"
subgraph "root" {
"[root] aws_s3_bucket.one (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/aws\"]"
"[root] aws_s3_bucket_versioning.one (expand)" -> "[root] aws_s3_bucket.one (expand)"
...
}
}
Two things are visible even in this fragment. The versioning resource points at the bucket — that's the edge your reference created. And every resource points at the provider node, which is the invisible fourth edge source doing its work.
Render it if you have Graphviz:
terraform graph | dot -Tsvg > graph.svg
Break it on purpose
Add a mutual reference — a tag on each bucket naming the other:
resource "aws_s3_bucket" "one" {
bucket = "tf-graph-demo-one-0725"
tags = { peer = aws_s3_bucket.two.id }
}
resource "aws_s3_bucket" "two" {
bucket = "tf-graph-demo-two-0725"
tags = { peer = aws_s3_bucket.one.id }
}
terraform plan
# UNVERIFIED — confirm against a real run
Error: Cycle: aws_s3_bucket.one, aws_s3_bucket.two
No plan is produced at all — the graph couldn't be built, so phases five and six never happened. Remove the cycle, then:
terraform destroy
Watch the destroy output: the versioning resource goes before the bucket it referenced. The graph, reversed.
In Practice
Reference, never hardcode. This is the most important habit on the page. These two are not equivalent:
# Correct: creates an edge. Terraform waits for the bucket.
resource "aws_s3_bucket_versioning" "app" {
bucket = aws_s3_bucket.app.id
}
# Broken: no edge. Terraform may attach versioning to a bucket that doesn't exist yet.
resource "aws_s3_bucket_versioning" "app" {
bucket = "tf-app-bucket-0725"
}
The second works most of the time, which is what makes it dangerous — it depends on the graph walk happening to visit the bucket first, and it will fail on a fresh apply to a new environment, in CI, at the worst moment. Every hardcoded value that could have been a reference is a hidden dependency. This is also the strongest practical argument for referencing over hardcoding, stronger than tidiness.
Provider configurations that depend on resources are the bootstrapping trap. The pattern looks reasonable: create a Kubernetes cluster, then configure the Kubernetes provider with its endpoint. But the provider node now depends on the cluster, and every resource using that provider inherits the dependency — so Terraform cannot even build a usable plan for those resources until the cluster exists, and you get errors about unknown values during planning rather than a clean ordering. The same shape appears whenever a provider's credentials or endpoint come from a resource in the same configuration.
The fix is separation rather than cleverness: create the platform in one configuration, consume it in another, with the second reading the first's outputs. That's a state-splitting decision, and it's Repo & Environment Structure.
depends_on belongs in a small number of real situations, and it's worth knowing them so you can
recognise when you're outside them. IAM permissions that must exist before a resource is created, even
though the resource's arguments don't mention the policy. Resources whose relationship is enforced by
the cloud but invisible in configuration — a service that must be enabled on a GCP project before an
API-backed resource can be created. And ordering across a module boundary that the module's inputs
don't express. Outside those, treat it as a symptom. Full argument in
Meta-Arguments.
Shape the graph deliberately when apply time matters. Two levers, and neither is a flag. Shorten the critical path: if the chain is bucket → versioning → policy → notification, ask whether any of those genuinely depend on each other or whether they all just depend on the bucket — fan-out is fast, chains are slow. And reduce fan-in on shared resources: if everything references one network, nothing starts until the network finishes, and moving the network into its own configuration removes it from the graph entirely.
What a reviewer should look for in a diff touching this topic:
- A hardcoded name, ARN or ID that duplicates a resource in the same configuration. That's a removed edge and an intermittent failure waiting to happen. Ask for the reference.
- A new
depends_on. Every one needs a sentence saying which real-world requirement it encodes and why no reference expresses it. "It didn't work without it" means the diagnosis isn't done. - A
depends_onalongside a reference to the same resource. Redundant; delete it. - A provider configuration that references a resource in the same configuration. Raise it as a design question, not a nit.
- A reference that was removed — replaced by a variable or literal. That silently deletes an edge, and the plan won't show it. This is the one a reviewer is most likely to miss.
Blast radius and rollback. The graph itself is not a runtime risk, but it determines how far a failure spreads: a failure on a node with many dependents stops all of them, so a broken shared resource halts the apply broadly, while a leaf failure is contained. Reverting a commit that changed references restores the previous graph, but any resources already created remain — the graph is recomputed from scratch each run and has no memory of the previous shape.
Ecosystem
terraform graph. Emits the graph as DOT for Graphviz. Genuinely useful for a specific question
about a small configuration or a named cycle; unreadable past roughly thirty nodes. -type=plan and
its siblings select which graph to emit — ⚠️ verify current supported values, as these have changed.
terraform apply -parallelism=N. Caps concurrent node visits. Worth lowering to diagnose
suspected rate-limiting, or when a provider misbehaves under concurrency. Raising it rarely helps and
often triggers throttling. Not a performance strategy.
Graphviz (dot). The renderer. terraform graph | dot -Tsvg > graph.svg is the whole
integration.
Rover, and similar visualisers. Interactive graph and plan visualisation, which is what
terraform graph output stops being at any real size. Worth a look when onboarding onto an unfamiliar
repository. ⚠️ verify current maintenance status before recommending — this category of tool has a
poor survival rate.
terraform show -json for dependency data. The plan JSON includes each resource change and its
dependencies, which is a more reliable programmatic source than parsing DOT. Useful for
custom checks — "nothing in this state may depend on that module" is a policy you can write.
Governance & Policy as Code.
TF_LOG=DEBUG. Logs the graph walk, so you can see which node the walker was visiting when
something failed and which nodes were in flight concurrently. The practical use is confirming that two
things you believed were ordered actually were.
Provider behaviour. Edge construction is Terraform's and identical everywhere. What differs is
graph shape for equivalent intent, per the tab set above, and how tolerant each provider's API is of
concurrency — which is what you're actually tuning when -parallelism appears to help.
Production
Security
Mostly indirect, with one direct case: depends_on is frequently the only thing ensuring an IAM policy
or role exists before the resource that needs it, and a missing edge there produces a resource created
without its intended permissions — which sometimes fails loudly and sometimes leaves something
functioning with broader access than designed. The other consideration is that graph output reveals
architecture; a rendered graph in a public repository is a free network diagram for anyone interested.
Blast radius
The graph decides propagation. A failure on a high-fan-in node — the network, the resource group, the shared key — stops everything downstream, so blast radius is a graph property before it's a permissions property. This is a concrete argument for extracting shared foundational resources into their own configuration: it doesn't just reduce plan time, it removes them from this graph so they cannot halt an application apply.
Scale
The graph is cheap to build and walk; that's essentially never the bottleneck. Apply duration is
governed by the critical path and by provider rate limits, in that order. Two thousand resources in a
wide graph apply far faster than four hundred in a deep one, and this is why "how many resources"
predicts apply time poorly. The levers are structural — shorten chains, reduce fan-in, split state —
rather than -parallelism. Scale & Performance.
Team workflow
Graph shape is a review concern, and mostly an invisible one: adding a reference is visible in a diff,
while removing one — replacing aws_s3_bucket.app.id with a literal — deletes an edge silently and
produces an intermittent failure weeks later on a fresh environment. Worth naming explicitly in a
review checklist, because no tool will flag it. A newly-added depends_on is the other item worth
challenging every time.
Reliability
Ordering failures split into two kinds. Cycles are safe — a hard error before anything happens, no infrastructure touched. Missing edges are not — they produce race conditions that pass in one environment and fail in another, and they're the reason a configuration that has worked for months fails on its first apply into a new region. The insurance is a periodic apply from empty into a throwaway environment: it's the only thing that exercises the graph from nothing, and it's the same drill as Failure & Recovery.
Interview Questions
Conceptual
How does Terraform decide what order to create resources in?
It builds a directed acyclic graph from the configuration and walks it, visiting nodes whose
dependencies are satisfied, several at a time. Edges come from four places: references to other
resources' attributes, explicit depends_on, module input and output wiring, and the provider
relationship. Textual order in the file is irrelevant — Terraform evaluates configuration rather than
executing it. Destroy uses the same graph with edges reversed.
What's the difference between an implicit and an explicit dependency, and which should you prefer?
An implicit dependency is inferred from a reference — using aws_s3_bucket.logs.arn in another
resource creates the edge automatically. An explicit one is declared with
depends_on = [aws_s3_bucket.logs].
Prefer implicit, always, for three reasons: it's precise, because the edge exists because a value is
genuinely needed; it's self-documenting, since the reader sees why; and it can't drift out of sync with
what the configuration actually uses. depends_on is for real relationships that no expression
captures — typically IAM — and it's coarse, waiting for the whole resource rather than a value.
Two resources are in the same file with no reference between them. What order do they run in?
Arbitrary, and concurrently. Being in the same file, the same module, or adjacent lines creates no
relationship whatsoever. If they genuinely must be ordered, that ordering is currently missing, and the
question to ask is whether one should reference the other — because if there's no value to reference,
you need depends_on and a comment explaining what the cloud requires.
Why does Terraform reject cycles instead of resolving them somehow?
Because there's no correct answer. A cycle means A must precede B and B must precede A, and any order
Terraform picked would violate a constraint it was told to honour — so it fails before touching
anything, which is the safe outcome. The error names the nodes involved. Cycles are always
configuration problems: mutual references between resources, a depends_on pointing back up an
existing chain, or create_before_destroy inverting an edge into a loop.
Does adding `-parallelism=50` make a large apply faster?
Usually not, and it can make things worse. Parallelism caps how many independent nodes are visited at once, so it only helps where the graph is genuinely wide and the provider tolerates the concurrency. Most slow applies are limited by the critical path — the longest chain of dependencies, which is strictly serial regardless of the cap — or by provider rate limiting, which more concurrency aggravates. The productive levers are structural: shorten chains, reduce fan-in on shared resources, split state.
Technical depth
What are the nodes in the graph? It's not just resources.
Resource instances, data sources, provider configurations, variables, locals, outputs, and module expansion nodes. Some do no work and exist purely to express ordering. This matters because a surprising number of ordering problems are about non-resource nodes — a data source that can't be read until a resource exists, or a provider configuration that depends on a resource attribute and therefore drags everything using that provider behind it.
Explain destroy ordering.
Terraform reverses the graph's edges. If the bucket policy depended on the bucket at create time, then at destroy time the bucket depends on the policy being destroyed first — which is right, because you can't remove a resource that still has dependents attached.
Two consequences. Destroy failures are frequently missing-edge problems, surfacing long after the
missing edge was introduced. And create_before_destroy inverts edges locally during replacement,
which is how one lifecycle setting produces a cycle error somewhere apparently unrelated.
What is a hidden dependency and why is it worse than a cycle?
A real-world ordering requirement with no edge in the graph — typically because a value was hardcoded where it could have been referenced, so Terraform sees no relationship and runs both concurrently.
It's worse than a cycle because a cycle is a hard failure before anything happens, whereas a hidden dependency is a race. It passes when the walk happens to pick a working order, and fails on a fresh apply into a new environment or under different concurrency — so it typically surfaces in CI or during a disaster recovery test, having "worked fine" for months. It's also invisible in review, since the diff that introduced it looks like a harmless literal.
Why is a provider configuration that references a resource a problem?
Because the provider becomes a graph node with a dependency, and every resource using that provider inherits it. The classic case is provisioning a Kubernetes cluster and configuring the Kubernetes provider from its endpoint: until the cluster exists, the provider's configuration is unknown, so Terraform can't plan the resources that use it and you get unknown-value errors during planning rather than a clean wait.
It isn't reliably fixable within one configuration, and the answer is to split: build the platform in one state, consume it in another that reads the first's outputs. Same reasoning applies to a provider whose credentials come from a resource in the same run.
How would you debug an apply where resource B failed because resource A didn't exist yet?
Establish whether an edge exists at all. Read B's configuration: does it reference A, or does it name
A with a literal? A literal is the answer most of the time, and the fix is the reference. If there is
no value of A that B needs, the relationship is invisible to Terraform and needs depends_on with a
comment saying what the cloud requires.
To confirm, terraform graph filtered to those two addresses shows whether the edge is present, and
TF_LOG=DEBUG shows which nodes were in flight concurrently during the failed run. Then verify the fix
properly by applying from empty into a throwaway environment — a re-apply into the half-built
environment will pass regardless, because A now exists, which is how these bugs survive.
How does this differ across AWS, Azure and GCP?
Edge construction doesn't differ at all — four sources, same rules, because it's Terraform's logic. The differences are in graph shape for the same intent.
Azure adds a layer. Resource groups are mandatory and resources reference them, so every Azure graph has a root node that AWS and GCP don't, and containers sit below storage accounts — the critical path is a node or two longer before you've written anything of your own.
AWS fans out. The provider splits object storage features into separate resources — versioning, policy, encryption, lifecycle configuration are each their own resource referencing the bucket — so AWS graphs are wide, with a large fan-out from a single node.
GCP is flatter. Equivalent settings are nested blocks inside one resource, so there are fewer nodes and fewer edges for the same configuration.
The practical consequence: comparing apply times across clouds compares graph shapes as much as API speeds. It also means AWS benefits more from parallelism than GCP does for the same intent, because there's more genuinely independent work.
Scenario and design
A configuration applies successfully in dev but fails on first apply to a new region. Diagnose.
Almost certainly a hidden dependency. Dev works because the resources already exist, so ordering never mattered; the new region is the first true from-empty apply, which is the only run that exercises the graph properly.
Look for hardcoded names, ARNs or IDs that duplicate resources in the same configuration — each one is a deleted edge. Then for real-world ordering the configuration can't express, typically IAM: a role policy that must exist before a resource is created. Then for data sources reading things the same configuration creates, which is a subtler version of the same bug.
The systemic fix is to make from-empty apply a routine event — a throwaway environment created and destroyed in CI — because that's the only test that catches this class, and reviewing for it is unreliable.
Your apply takes 40 minutes for 300 resources. Where do you look?
Separate the two possible causes before touching anything. Get the timing from the apply log: are resources completing quickly but sequentially, or slowly in parallel?
Sequential completion means the critical path dominates, and the fix is graph shape — find the longest chain and ask whether each link is genuine, because chains are often accidental: three resources that each depend on the bucket got written as a chain because that's the order someone thought of them. Parallel-but-slow means either the resources are genuinely slow to create, which no change fixes, or the provider is rate-limiting, visible as retries in debug logs, in which case lowering parallelism can improve throughput.
If a small number of shared resources have very high fan-in, extracting them to their own configuration removes them from the graph and shortens every subsequent apply. Beyond that it's Scale & Performance.
A colleague fixes an ordering bug by adding `depends_on` and the PR is otherwise clean. Do you approve?
Not without knowing what was diagnosed. depends_on makes ordering problems disappear whether or not
you understood them, so the question to ask is: which value does the dependent resource need from the
other one? If there is one, the correct fix is a reference — more precise, self-documenting, and it
can't fall out of sync. If the resource is currently hardcoding that value, depends_on treats the
symptom while leaving the actual defect, which is the missing reference.
depends_on is right when the relationship genuinely isn't expressible as a value — IAM being the
usual case — and then it deserves a comment saying what the cloud requires. So: approve if the answer
is "no value connects these, and here's why", ask for the reference otherwise.
You need to provision a cluster and then configure resources inside it. How do you structure it?
Two configurations, not one. Configuring a provider from a resource created in the same run means the
provider node depends on that resource, every resource using the provider inherits the dependency, and
Terraform can't plan those resources before the cluster exists — producing unknown-value errors at plan
time rather than an orderly wait. depends_on doesn't fix it, because the problem is that the
provider's configuration is unknown during planning, not that the ordering is wrong.
So: one configuration creates the platform and exposes its endpoint and credentials as outputs; a
second consumes them, either via terraform_remote_state or, better, by reading the cluster through a
data source so the coupling is to the real world rather than to another state file. This also gives
each a sensible blast radius and lets the platform change on a different cadence to what runs on it.
See Repo & Environment Structure.
Commands & Gotchas
terraform graph # emit the graph as DOT
terraform graph | dot -Tsvg > graph.svg # render it
terraform graph | grep aws_s3_bucket.app # the edges touching one address
terraform apply -parallelism=1 # serialise, to isolate an ordering problem
terraform apply -parallelism=30 # widen — rarely helps, often throttles
TF_LOG=DEBUG TF_LOG_PATH=tf.log terraform apply # which nodes ran concurrently, and when
terraform show -json | jq '.values.root_module.resources[].depends_on' # recorded dependencies
terraform state list # the addresses the graph is built from
| Behaviour | Why it matters |
|---|---|
| Only four things create edges | Reference, depends_on, module wiring, provider. Nothing else |
| File and module position create no ordering | Same file, same module, adjacent lines — all irrelevant |
| A hardcoded value where a reference would do deletes an edge | Silent, intermittent, and invisible in review. The bug to look for |
| Destroy reverses every edge | Destroy failures are usually missing-edge problems surfacing late |
| Apply duration is floored by the critical path | A hundred independent resources beat a five-deep chain |
| High fan-in serialises everything behind one node | If all resources reference the network, nothing starts until it's done |
depends_on takes static resource references only |
No attributes, no expressions — it's resolved before values exist |
| A provider configured from a resource drags all its users | The bootstrapping trap. Split the configuration instead |
| Cycles fail before anything is touched | Safe. Hidden dependencies are the dangerous failure |
terraform graph is illegible past ~30 nodes |
A tool for one question, not for documentation |
← Back to The Machinery · Next: Meta-Arguments →
⚠️ Verification checklist (delete before publishing)
Command output
- The concurrent-creation output for three buckets — confirm
Creating...lines really interleave before completions, and capture real timings. The parallelism lesson depends on this block. -
terraform graphDOT output — the exact header (digraph {,compound,newrank), the[root]prefix, and whether nodes still carry the(expand)suffix. ⚠️ verify; this format has changed between versions and my excerpt may be from an older one. - The cycle error format. I wrote
Error: Cycle: aws_s3_bucket.one, aws_s3_bucket.two— confirm the exact rendering, and whether it lists nodes on one line or several. - Confirm the mutual-tag-reference example actually produces a cycle rather than some other error.
- Destroy output ordering, showing the versioning resource destroyed before its bucket.
-
terraform show -json | jq '...depends_on'— verify that path exists in the state JSON and returns what's implied.
Behavioural claims
- Default parallelism is 10 — flagged inline, same item as the lifecycle page. Verify once.
- That
depends_onaccepts only static references to whole resources/modules, no attributes and no expressions. - That
create_before_destroyinverts edges and can produce a cycle elsewhere. Asserted twice and used in an interview answer, but it's really Meta-Arguments' claim — verify there and keep both pages consistent. - That lowering
-parallelismcan improve throughput under provider rate limiting. Plausible and widely repeated; ⚠️ verify before stating it as fact in an interview answer. -
terraform graph -type=supported values. Flagged inline — these have changed and some are removed. - That the provider-configured-from-a-resource case produces unknown-value errors at plan time rather than merely ordering awkwardly. This claim carries two interview answers.
Provider-specific claims in the tab set
- AWS: that bucket features (versioning, policy, encryption, lifecycle) are genuinely separate
resources in the current provider version, and that
aws_s3_bucket_versioningis the right resource name. - Azure: that
azurerm_storage_containerstill takesstorage_account_name— ⚠️ verify, this may have moved tostorage_account_idin azurerm 4.x. - GCP: that
versioningis a nested block ongoogle_storage_bucket, and thatgoogle_storage_bucket_iam_memberis correct. - The generalisation that AWS graphs are deep/wide, Azure one layer deeper, GCP flatter. Reasonable but sweeping — confirm against real graphs for the three configurations shown.
- ⚠️ The GCP example grants
allAuthenticatedUsersobject-viewer access. That's a public-ish grant in a teaching example and a reviewer will flag it. Change it to a specific principal before publishing.
Versions and syntax
-
~> 5.0aws constraint — stale, fix across all five pages in one pass. - Rover's maintenance status before recommending it.
Rendering and structure
- One
<Tabs>block, for minimal-graph shape. Confirm the AWS/GCP tabs being shorter than Azure's reads as the point being made rather than as an omission — prose above the block says so, check it's prominent enough. - All relative links resolve.