Skip to main content
RunBook Academy

TerraformII · Terraform ArchitectureProduction Terraform

The Dependency Graph and Parallelism

Intermediate⏱ ~14 minbash

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

Not yet marked complete on this device.

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_each keys: 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 graph prints.
  • 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 modeObservable symptomRecovery
1Implicit dependency missedResource created before its dependency is ready; provider returns a “not found” errorAdd an explicit reference or depends_on; re-plan; re-apply
2Cycle in the graphError: Cycle: aws_a.x, aws_b.yIdentify the cycle; remove a reference or add a depends_on to break it
3Destroy-time dep wrongdepends_on was added for create but blocks correct destroy orderUse lifecycle.create_before_destroy instead of depends_on for resources that need a specific create order
4terraform graph output too largeGraphviz runs out of memory rendering a 5,000-node graphFilter the graph with -module=... or apply dot filters; use terraform show -json to inspect specific resources instead
5Provider’s internal ordering conflicts with Terraform’sProvider expects sequential creation (some database resources); concurrent creates failAdd depends_on to serialise the affected resources; reduce -parallelism
6count / for_each edges create unexpected parallelismA count = 100 resource creates 100 concurrent API callsSet -parallelism to match the provider rate limit; consider a separate apply for large count resources

Security implications

  • terraform graph output 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 graph output as a programmatic API; use terraform show -json instead.
  • 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 -parallelism for destroy is safe for cloud providers; many operators use -parallelism=50 for destroy and -parallelism=10 for apply.

Production guidance

  1. Let references carry dependencies. Do not add depends_on unless an implicit reference is impossible. Every explicit dependency is a place where the configuration lies about the actual relationship.
  2. Render the graph when debugging unexpected ordering. A picture is faster than reasoning about a DAG.
  3. Use lifecycle.create_before_destroy for stateful resources. Databases, key material, anything with persistent state.
  4. Tune -parallelism per provider. Default 10 is reasonable. Increase for destroy, never beyond the provider’s rate limit.
  5. Use terraform graph only for humans. For programmatic inspection, use terraform 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_on appropriate, and when is it an antipattern?
  • What is destroy-time graph reversal, and what configuration flag affects it?
  • Why is the default -parallelism of 10 often a good production setting?

Knowledge check · 7 questions

  1. Q1. How does Terraform determine the apply order for resources?

  2. Q2. What kind of dependency does this HCL create: aws_subnet.public.vpc_id = aws_vpc.main.id?

  3. Q3. depends_on is the recommended way to declare most resource dependencies.

  4. Q4. When Terraform destroys resources, what happens to the graph?

  5. Q5. Which of the following produce graph edges? (Select all that apply.)

  6. Q6. What is the default value of -parallelism for terraform apply in 1.9.x?

  7. 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.