TerraformII · Terraform ArchitectureProduction Terraform
The Dependency Graph and Parallelism
What you'll learn
- Explain how Terraform builds a resource directed acyclic graph from configuration references
- Distinguish implicit dependencies from explicit depends_on
- Recognise that the graph defines execution order, not declaration order
- Apply the parallelism setting to production applies
- Recognise destroy-time graph reversal and its production consequences
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
Terraform does not apply resources in the order they appear in
your .tf files. It builds a directed acyclic graph from the
references between resources and walks the graph topologically.
This is the single biggest reason Terraform is fast and safe in
production: independent resources apply concurrently, and
dependent resources apply in the right order. If the graph is
wrong, the apply is wrong.
Why a graph, not a list
A naive executor would apply resources in declaration order:
resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16" }
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
}
resource "aws_instance" "web" {
ami = "ami-0e1bed4f"
subnet_id = aws_subnet.public.id
}
A list executor applies the VPC, then waits for it, then applies the subnet, then waits, then applies the instance. Three serial operations.
A graph executor builds a DAG:
aws_vpc.main ---> aws_subnet.public ---> aws_instance.web
The instance must wait for the subnet, and the subnet must wait for the VPC. But if the configuration has many independent resources — for example, ten VPCs that each have one subnet and one instance — the graph executor can apply all three layers of all ten stacks concurrently, bounded only by the parallelism limit.
vpc.a vpc.b vpc.c vpc.d vpc.e
| | | | |
sub.a sub.b sub.c sub.d sub.e
| | | | |
ec2.a ec2.b ec2.c ec2.d ec2.e
A list executor takes 30 sequential operations. A graph executor takes 3 sequential layers, each running up to 10 concurrent operations.
Implicit dependencies from references
Most dependencies in Terraform are implicit: they are inferred from HCL expressions that reference another resource’s attributes.
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id # implicit dependency on aws_vpc.main
cidr_block = "10.0.1.0/24"
}
The reference aws_vpc.main.id creates a graph edge from
aws_subnet.public to aws_vpc.main. Terraform analyses the
expression, finds the reference, and adds the edge. The edge
appears in the graph but never appears in your HCL.
This is intentional. The HCL stays simple; the graph carries the ordering constraints.
References that create graph edges:
- Resource attributes:
aws_vpc.main.id - Resource blocks:
aws_subnet.public.id,aws_subnet.public.cidr_block - Module outputs:
module.network.vpc_id - Variable interpolation:
var.subnet_cidr— variables do not create graph edges (they are inputs, not resources). for_eachkeys:aws_instance.web[each.key]— creates an edge to the resource that produces the map.
Explicit dependencies with depends_on
For ordering that does not come from data flow, use depends_on:
resource "aws_iam_role_policy" "deploy" {
role = aws_iam_role.deploy.id
policy = data.aws_iam_policy_document.deploy.json
}
# The instance does not read any attribute of the policy,
# but it cannot exist before the policy is attached.
resource "aws_instance" "web" {
ami = "ami-0e1bed4f"
instance_type = "t3.medium"
depends_on = [aws_iam_role_policy.deploy]
}
Use depends_on when:
- A resource’s lifecycle depends on another resource’s lifecycle but no attribute flows between them.
- You need to enforce ordering against a provider that has cross-resource eventual consistency issues.
- You are working around a known provider bug.
Avoid depends_on when an implicit reference would do. Explicit
dependencies add edges that show up in terraform graph output
and clutter the picture. If you can reference an attribute,
reference it.
The graph vs the execution order
Two related but distinct concepts:
- The graph is a DAG. Every resource is a node. Every
reference is an edge. The graph is what
terraform graphprints. - The execution order is the topological sort of the graph. It is the order in which the apply walker visits resources.
terraform graph
digraph {
compound = "true"
...
"aws_subnet.public" -> "aws_vpc.main"
"aws_instance.web" -> "aws_subnet.public"
"aws_route_table_association.public" -> "aws_subnet.public"
"aws_route_table_association.public" -> "aws_route_table.public"
"aws_security_group.web" -> "aws_vpc.main"
...
}
The output is in DOT format, the same format Graphviz consumes. A useful debugging tool is to render the graph as a picture:
terraform graph | dot -Tpng > graph.png
For a 50-resource configuration, this produces a readable diagram. For a 1,000-resource configuration, it produces a spaghetti diagram that is still useful for finding unexpected edges.
Destroy-time graph reversal
When Terraform destroys resources, the graph is reversed. Resources that depended on others must be destroyed before the resources they depended on.
Create order: vpc -> subnet -> instance -> eip
Destroy order: eip -> instance -> subnet -> vpc
depends_on is honoured in both directions: if instance has a
depends_on on eip, then on destroy the eip waits for the
instance to finish, because reversing the edge keeps the same
ordering constraint.
The reversal is the source of a common production failure:
lifecycle.create_before_destroy = true. By default, Terraform
destroys a resource before creating its replacement. With
create_before_destroy, Terraform creates the replacement first
and destroys the old one only after the new one is healthy. This
matters for stateful resources where destroy is irreversible
(database volumes, encryption keys).
resource "aws_db_instance" "primary" {
engine = "postgres"
instance_class = "db.t3.medium"
allocated_storage = 100
lifecycle {
create_before_destroy = true
}
}
For a database, create_before_destroy is mandatory; destroying
the old DB before creating the new one causes data loss.
-parallelism and its limits
terraform apply -parallelism=20
terraform apply runs up to -parallelism resource operations
concurrently. The default is 10. The graph determines which
resources can run concurrently (those with no unsatisfied
dependencies); -parallelism determines how many can run at
once.
terraform destroy has the same flag. Destroy parallelism is
often more important than create parallelism, because
independent resources can be torn down concurrently.
For a 1,000-resource configuration with no inter-resource
dependencies, -parallelism=10 means the apply runs in
approximately 100 sequential layers of 10 resources each. With
-parallelism=50, it runs in 20 layers. With
-parallelism=1000, it runs in 1 layer — but the provider is
overwhelmed by 1,000 concurrent API calls and most of them fail.
The right value is the largest number that the provider can absorb without rate-limiting. Empirically, 10 is reasonable for AWS, GCP, and Azure; 5 is reasonable for smaller providers or for very large creates against rate-limited endpoints.
Production failure modes
| # | Failure mode | Observable symptom | Recovery |
|---|---|---|---|
| 1 | Implicit dependency missed | Resource created before its dependency is ready; provider returns a “not found” error | Add an explicit reference or depends_on; re-plan; re-apply |
| 2 | Cycle in the graph | Error: Cycle: aws_a.x, aws_b.y | Identify the cycle; remove a reference or add a depends_on to break it |
| 3 | Destroy-time dep wrong | depends_on was added for create but blocks correct destroy order | Use lifecycle.create_before_destroy instead of depends_on for resources that need a specific create order |
| 4 | terraform graph output too large | Graphviz runs out of memory rendering a 5,000-node graph | Filter the graph with -module=... or apply dot filters; use terraform show -json to inspect specific resources instead |
| 5 | Provider’s internal ordering conflicts with Terraform’s | Provider expects sequential creation (some database resources); concurrent creates fail | Add depends_on to serialise the affected resources; reduce -parallelism |
| 6 | count / for_each edges create unexpected parallelism | A count = 100 resource creates 100 concurrent API calls | Set -parallelism to match the provider rate limit; consider a separate apply for large count resources |
Security implications
terraform graphoutput is text, but it is sensitive. The graph contains every resource address, every module path, and every reference. In a security-sensitive environment, this leaks the topology of your infrastructure.- Graph output is not stable across Terraform versions. The
DOT format and the node identifiers can change. Do not parse
terraform graphoutput as a programmatic API; useterraform show -jsoninstead. - The graph is built from your configuration. Configuration leaks. The course returns to configuration secrets in the secrets chapter.
Performance implications
- Graph construction is O(N) in the number of resources. A 5,000-resource configuration parses and graphs in under a second.
- Plan time scales with graph size and provider latency. Each
resource requires a refresh (one read API call) and a plan
call. A 5,000-resource plan against a 100 ms-per-call provider
takes 500 seconds of provider time; with
-parallelism=10, about 50 seconds of wall-clock time. - Destroy parallelism is often the bottleneck. A 5,000-resource
destroy at default parallelism takes 500 sequential layers.
Increasing
-parallelismfor destroy is safe for cloud providers; many operators use-parallelism=50for destroy and-parallelism=10for apply.
Production guidance
- Let references carry dependencies. Do not add
depends_onunless an implicit reference is impossible. Every explicit dependency is a place where the configuration lies about the actual relationship. - Render the graph when debugging unexpected ordering. A picture is faster than reasoning about a DAG.
- Use
lifecycle.create_before_destroyfor stateful resources. Databases, key material, anything with persistent state. - Tune
-parallelismper provider. Default 10 is reasonable. Increase for destroy, never beyond the provider’s rate limit. - Use
terraform graphonly for humans. For programmatic inspection, useterraform show -json.
Verification
- Why does Terraform build a graph instead of applying resources in declaration order?
- What is the difference between an implicit dependency and an
explicit
depends_on? - When is
depends_onappropriate, and when is it an antipattern? - What is destroy-time graph reversal, and what configuration flag affects it?
- Why is the default
-parallelismof 10 often a good production setting?
Knowledge check · 7 questions
Q1. How does Terraform determine the apply order for resources?
Q2. What kind of dependency does this HCL create: aws_subnet.public.vpc_id = aws_vpc.main.id?
Q3. depends_on is the recommended way to declare most resource dependencies.
Q4. When Terraform destroys resources, what happens to the graph?
Q5. Which of the following produce graph edges? (Select all that apply.)
Q6. What is the default value of -parallelism for terraform apply in 1.9.x?
Q7. A team reports that a database resource is being destroyed before its replacement is healthy. The apply log shows 'aws_db_instance.primary: Destruction complete' followed by a long wait before the new instance is created. What configuration flag should the team add?
Passing score: 75%. Answers are checked in this browser.