Resource Dependencies
Real infrastructure is rarely a flat list of independent objects: a subnet lives inside a VPC, a route table attaches to that subnet, and an instance launches into it. Terraform models these relationships as a directed graph, and the most common way edges get added to that graph is implicitly — simply by referencing one resource’s attribute from another. Understanding how these implicit dependencies form is the key to predictable plans, correct create/destroy ordering, and avoiding the dreaded dependency cycle. This applies identically to OpenTofu, which uses the same graph engine and HCL2 syntax.
How references create dependencies
Whenever you interpolate an attribute of one resource into the configuration of another, Terraform records an edge between them. You do not declare the relationship — it is inferred from the expression. The referenced resource must be created (and have its attributes known) before the referencing resource can be planned and applied.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
tags = {
Name = "main"
}
}
resource "aws_subnet" "app" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "us-east-1a"
tags = {
Name = "app"
}
}
Because aws_subnet.app reads aws_vpc.main.id, Terraform knows the VPC must exist first. The id is a computed attribute — its value is only known after the VPC is created — so Terraform schedules the VPC create, then feeds the resulting id into the subnet create. No depends_on is needed; the reference is the dependency.
Prefer implicit dependencies over
depends_on. References are precise (Terraform knows exactly which attribute flows where) and self-documenting. Reach fordepends_ononly when a real ordering requirement exists that Terraform cannot see from the configuration.
The dependency graph
Terraform walks your configuration, builds a graph of every resource and data source, and inserts edges for each reference. It then performs a topological sort to determine a valid execution order, parallelizing nodes that have no relationship between them (up to -parallelism, default 10).
You can inspect the graph directly:
terraform graph | dot -Tsvg > graph.svg
For the configuration above, an apply produces ordered, dependency-aware output.
Output:
aws_vpc.main: Creating...
aws_vpc.main: Creation complete after 2s [id=vpc-0a1b2c3d4e5f60718]
aws_subnet.app: Creating...
aws_subnet.app: Creation complete after 1s [id=subnet-09f8e7d6c5b4a3210]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
On destroy, Terraform walks the graph in reverse: the subnet is destroyed before the VPC, because you cannot delete a VPC that still contains a subnet.
Implicit vs. explicit dependencies
| Aspect | Implicit (reference) | Explicit (depends_on) |
|---|---|---|
| How it’s declared | vpc_id = aws_vpc.main.id | depends_on = [aws_vpc.main] |
| Granularity | Per-attribute, precise | Whole-resource, coarse |
| Self-documenting | Yes — value flows visibly | No — intent is implicit |
| When to use | Almost always | Hidden ordering (e.g. IAM policy must exist before a service uses it) |
| Risk | Low | Can mask design issues; overuse hurts parallelism |
A larger worked example
Dependencies chain transitively. Here an internet gateway, a route table, and an association all hang off the VPC and subnet, forming a small graph.
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.app.id
route_table_id = aws_route_table.public.id
}
Terraform derives the order from the references alone: the VPC and subnet first, then the gateway, then the route table (which needs the gateway id), and finally the association (which needs both the subnet and route table). The association depends on five resources transitively without a single depends_on.
Avoiding cycles
A cycle occurs when resource A references B and B (directly or through a chain) references A. The graph then has no valid topological order, and Terraform refuses to proceed.
Output:
╷
│ Error: Cycle: aws_security_group.app, aws_security_group.db
│
╵
This commonly happens when two security groups reference each other in their inline rules. The fix is to break the inline rule out into a standalone resource so the groups can be created first and wired together afterward:
resource "aws_security_group" "app" {
name = "app"
vpc_id = aws_vpc.main.id
}
resource "aws_security_group" "db" {
name = "db"
vpc_id = aws_vpc.main.id
}
resource "aws_security_group_rule" "app_to_db" {
type = "egress"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_group_id = aws_security_group.app.id
source_security_group_id = aws_security_group.db.id
}
Now both groups are created independently, and the rule — created last — references both. The cycle is gone because the dependency only flows one direction at each node.
If you hit a cycle, run
terraform graph | dot -Tsvg > graph.svgand trace the loop visually. The cause is almost always two resources that each reference an attribute of the other.
Best practices
- Let references do the work — model ordering through attribute interpolation rather than
depends_onwherever a value actually flows between resources. - Reference computed attributes (like
idorarn) instead of hardcoding values, so Terraform sees the true dependency and recreates downstream resources when an upstream one changes. - Reserve
depends_onfor genuinely hidden ordering, such as side effects an API performs that aren’t visible in any returned attribute. - Break mutual references (especially between security groups) into standalone rule resources to keep the graph acyclic.
- Visualize the graph with
terraform graphwhen ordering surprises you or a cycle appears. - Keep dependency chains shallow; deep transitive chains serialize your apply and slow it down by defeating parallelism.