The Graph You Already Write: References Are Ordering
Terraform never plans resources in file order — it plans them in dependency order, and it builds that ordering from your references automatically. Watch a small stack declare its own schedule:
resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" }
resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id # edge: subnet after VPC
cidr_block = "10.0.1.0/24"
}
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id # edge: instance after the AMI lookup
subnet_id = aws_subnet.private.id # edge: instance after the subnet
}
output "app_ip" {
value = aws_instance.app.private_ip # edge: output after the instance
}
Every reference — any expression that traverses into another object — is an edge: VPC before subnet before instance before output. Terraform assembles all the edges into a directed acyclic graph (DAG) and plans against that graph, not against the lines on the page. Two consequences follow immediately and are worth being able to recite. First, independent branches run concurrently: two subnets that both reference only the VPC are created in parallel (bounded by parallelism, which the third chapter covers), so a stack of fifty unrelated resources takes minutes, not fifty sequential API waits. Second, ordering is transitive and automatic: you never write "create the VPC first" — the moment vpc_id = aws_vpc.main.id exists, the constraint does too, and deleting the reference deletes the constraint. Both properties make references the cheapest form of dependency: they order exactly as much as the data flow requires, cost no parallelism beyond what the values themselves demand, and cannot outlive their reason for existing, because a reference that stops compiling is a reference you already removed.
The same graph runs backwards on destroy: dependents are removed before their dependencies, so the instance dies before the subnet and the subnet before the VPC. One traversal rule matters in practice: referencing a whole object (like aws_vpc.main) versus an attribute (aws_vpc.main.id) creates the same kind of edge, but only attribute references give Terraform a value to compute with — a whole-object reference in an ordinary argument is a type error, and that asymmetry is what depends_on exists to resolve. The graph is also where "why is my apply waiting?" gets answered: a resource with inbound edges from unfinished work simply cannot start, and the edge is always traceable to a specific reference in a specific file — usually the one you added five minutes ago without thinking about ordering at all.
You can see the graph you have written at any time:
terraform graph -type=plan # GraphViz DOT on stdout — pipe to dot -Tsvg for a picture
The output is dense, but the shape is the deliverable: nodes with inbound edges wait; nodes without them start immediately. When an apply seems to "hang" on one resource while doing nothing visible, the graph is the first exhibit — and the most common finding, which the final chapter develops, is that the waiting is either real (a dependency genuinely still creating) or self-inflicted (an depends_on someone added defensively and never revisited). On large stacks, filtering the DOT output to the suspect address and its neighbors beats rendering thousands of edges into a picture nobody can read.
depends_on: Ordering When No Value Flows Between the Objects
Some ordering requirements leave no value to reference. A classic: an EC2 instance whose bootstrap script uses an IAM instance profile — the name of the profile is not something the instance's arguments consume, but the profile and its role-policy attachment must exist before the instance boots, or the script's metadata call fails. Nothing in the instance body would naturally reference the policy, so you state the requirement directly:
resource "aws_iam_role_policy" "app" {
name = "app-policy"
role = aws_iam_role.app.id
# ...
}
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
iam_instance_profile = aws_iam_instance_profile.app.name
depends_on = [aws_iam_role_policy.app] # ordering edge, no value consumed
}
depends_on is a meta-argument: Terraform consumes it as pure graph structure — it never reaches the provider — and it accepts a list of whole object references only. Writing depends_on = [aws_iam_role_policy.app.name] fails validation with an "Invalid depends_on reference" error, because ordering is a statement about objects, not about values: if you need the value, that is a regular reference; if you need the sequence, that is depends_on. The distinction keeps the graph honest — every edge in an implicit-reference graph can be traced to a value that had to exist; every depends_on edge is a human assertion that should come with a reason you could defend in review.
The costs are real enough to name before you reach for the flag. A depends_on edge forces strict sequencing: the dependent waits for the dependency's entire completion, even when only part of it would have sufficed, and it removes the parallelism that same branch had a moment before. It also creates ordering that no diff can ever invalidate — if the IAM policy stops mattering months later, the edge silently remains, serializing a job nobody remembers writing. And because lifecycle settings are consumed while the graph is built, the list must be literal addresses — no computed values, no conditionals — so there is no way to make an edge "expire" on a schedule; expiry happens only by editing the file. The professional habit is narrow: use depends_on for genuine side-effect ordering that references cannot express (IAM propagation before bootstraps, a peering connection before route creation, a bucket policy before the Lambda that reads it), attach a comment stating the why, and prefer restructuring to avoid it when a natural reference is available. Modern Terraform also accepts depends_on on module calls and on data sources — the data source case is quietly important: it defers the read until after the named objects are created, which is exactly right when a lookup would otherwise race a resource it depends on existing. On a module call, depends_on orders the entire module subtree after the named objects — a heavy tool, since it orders every resource inside, so reserve it for modules whose side effects (not just values) must precede the caller's next step, such as a monitoring-module whose alarms should exist before traffic is routed.
The next time two engineers argue about creation order in a review, the arbiter is the graph: terraform graph -type=plan shows what Terraform will do, and terraform show -json plans (consumed by policy tooling) enumerate the same relationships — opinions about ordering become verifiable claims.
Parallelism, Over-ordering, and Debugging a Graph That Looks Stuck
Terraform applies operations concurrently, bounded by parallelism: by default the CLI runs up to 10 operations at a time (-parallelism=N overrides it; HCP Terraform workspaces expose the same knob). On a wide graph — a for_each producing forty subnets off one VPC — those forty creates fan out across ten workers until the queue drains, and the difference between a healthy graph and a serialized one is visible in wall-clock time:
aws_subnet.private[0]: Creation complete after 4s [id=subnet-0aaa]
aws_subnet.private[1]: Creation complete after 4s [id=subnet-0bbb]
...
Apply complete! Resources: 41 added, 0 changed, 0 destroyed. (2m11s)
depends_on is the tool that quietly deletes that fan-out. Every defensive edge you add turns parallel branches into waiting queues — three resources that each depends_on a shared prerequisite that itself has no reason to precede them serializes nine operations into a staircase. The telltale signature in apply output is a long silence after one resource completes while "still creating..." lines sit untouched: Terraform is honoring edges, and the graph view shows exactly which edge is holding the gate. When you see that pattern, interrogate every incoming edge on the blocked node — the ones referencing values are usually real; the depends_on entries are usually candidates for deletion.
Two related tools deserve honest framing. -target=<address> narrows an apply to one node and its dependencies — genuinely useful for unblocking a single wedged object, genuinely dangerous as a routine (it plans a subset, silently ignoring the state of everything else, which is how "temporary" targeted applies become permanent configuration drift); use it like a scalpel and follow with a full apply. And sometimes the wait is not Terraform's fault: IAM role and policy propagation genuinely takes up to a minute before an instance profile is usable by a bootstrapping instance, so a script that races the metadata service needs the ordering edge, a retry loop in the bootstrap, or both — real-world justification for an otherwise unjustifiable depends_on. Lowering parallelism (-parallelism=1) has a place too: careful, ordered rollouts against rate-limited APIs, or bisecting a flaky provider interaction.
Graph-shape questions also have a failure mode worth recognizing: cycles, where two objects reference each other and no valid order exists. Terraform detects them at plan time and names the loop — "Cycle: aws_instance.a → aws_security_group.app → aws_instance.a" — so the error is a diagnosis, not a shrug. The standard repairs are the ones the graph suggests: remove a reference that was only there "for completeness", move the shared value into a local, or break a two-way association (an instance profile referencing the instance and the instance referencing the profile) into a one-way edge plus a depends_on for the side-effect half. Most cycle repairs take five minutes once the loop is visible — and terraform graph -type=plan is what makes it visible.
The complete debugging loop for graph questions is short enough to memorize: terraform graph -type=plan for structure, the apply output's timestamps for where time is actually going, and terraform show -json of a saved plan for machine-readable relationships. Most "Terraform is slow" investigations end in one of two findings — over-ordering you added defensively, or an API that legitimately takes time — and the graph tells you which in under a minute. With ordering understood at the graph level, the last resource-level control in this phase is behavioral: what a single object does on replacement and destruction, in The lifecycle Meta-argument.
Quiz
1. Two subnets reference aws_vpc.main.id and nothing references each other. How does Terraform plan them?
2. What does depends_on accept, and what happens if you pass an attribute reference?
3. What is the main cost of an unnecessary depends_on edge?
4. An apply's output shows one resource complete and others sitting in "still creating..." for minutes. What is your first diagnostic move?
Flashcards
Question
What is the dependency graph, what creates its edges, and what are its two planning consequences?
Click to reveal answer
Answer
Terraform assembles a directed acyclic graph from references between objects (and explicit depends_on edges). Consequences: independent branches plan and apply concurrently (bounded by parallelism), and destroy traverses the graph in reverse, so dependents are removed before their dependencies. File order never matters.
Question
When is depends_on the right tool, and what three restrictions apply?
Click to reveal answer
Answer
For genuine side-effect ordering no value can express (IAM propagation before bootstrap, peering before routes). Restrictions: whole-object references only (attributes are rejected), it adds hard serialization, and it never expires — it stays in the graph after the reason disappears, so review it and comment the why.
Question
What is the default parallelism, how do you change it, and why would you lower it?
Click to reveal answer
Answer
Default 10 concurrent operations, changed with `-parallelism=N` (or workspace settings in HCP Terraform). You lower it — even to 1 — for rate-limited APIs, careful ordered rollouts, or to bisect a flaky provider interaction during incident debugging.
Question
How do you inspect the dependency graph, and what does "still creating..." during silence usually mean?
Click to reveal answer
Answer
`terraform graph -type=plan` prints GraphViz DOT; pipe to `dot -Tsvg` for a picture. Silence after one resource completes with others waiting means Terraform is honoring an incoming edge — usually a defensive depends_on serializing the branch; check the blocked node's edges first.
Question
How do depends_on and -target differ in risk profile?
Click to reveal answer
Answer
depends_on hard-codes ordering that persists invisibly and costs parallelism; `-target` plans a subset of the graph, ignoring everything else — safe for unblocking one wedged object, dangerous as routine because repeated targeted applies leave the rest of the system unreconciled (drift by omission).
Revision Notes
Key Takeaways
- 1. References are dependency edges: Terraform plans a DAG, runs independent branches concurrently (parallelism 10 by default), and destroys in reverse graph order — file order is irrelevant
- 2. `depends_on` is pure ordering for side effects no value expresses: whole-object references only, hard serialization, and edges that never expire — comment the reason or regret it later
- 3. Every unnecessary `depends_on` deletes parallelism; the "silent apply" signature points straight at an incoming edge, and `terraform graph -type=plan` names it
- 4. Real waits exist (IAM propagation up to a minute), and the fix is ordering plus bootstrap retries — not more `depends_on` sprinkled until the symptom hides
- 5. `-target` is scalpel surgery on the graph; routine use silently skips reconciliation of everything else, so follow any targeted apply with a full one
Interview Tips
- • "How does Terraform decide creation order?" — it builds a DAG from references (and depends_on), plans independent nodes concurrently, and reverses traversal on destroy; add that `terraform graph -type=plan` makes those claims verifiable
- • "When do you use depends_on versus a reference?" — reference when you consume a value (ordering comes free); depends_on only for side-effect ordering with no value flow — IAM before bootstrap, peering before routes — and always with a comment justifying the serialization
- • "How do you speed up a slow apply?" — look for defensive depends_on edges serializing branches (graph view), verify independent work is actually independent, then raise -parallelism if the provider's API rate limits allow it
- • "A peer's apply seems stuck. What do you check?" — the blocked node's incoming edges in `terraform graph`, then the lock (`Error acquiring the state lock` means someone else holds it), then whether the wait is a real long-running API; order matters because each has a different fix
Cheat Sheet
Dependencies & the Graph
Implicit (references): any traversal — aws_subnet.x = aws_vpc.main.id — adds an edge. DAG → concurrent independent branches, transitive ordering, reverse-order destroy. Whole-object reference vs attribute reference: same edge kind; only attributes carry values.
Explicit (depends_on):
resource "aws_instance" "app" {
# ...
depends_on = [aws_iam_role_policy.app] # whole objects only; side-effect ordering
}
Costs: serialization of that branch, invisible to diffs, never expires. Valid on resources, data sources (defers reads), and module calls.
Runtime knobs & diagnostics:
| Tool | Use |
|---|---|
-parallelism=N |
concurrency bound (default 10; 1 for careful rollouts) |
terraform graph -type=plan |
visualize blocking edges (GraphViz DOT) |
-target=<addr> |
single-object surgery only — plans a subset, risks drift |
| apply output timestamps | where wall-clock time actually goes |