TerraformVIII · Dependencies and the Resource GraphDependencies
Implicit Dependencies Through References
What you'll learn
- Explain how Terraform infers implicit dependencies from attribute references
- Identify which expressions create edges in the resource graph
- Recognise the silent-misconfig risk of an incidental reference
- Use locals to separate incidental references from real ordering constraints
Prerequisites
None — start here.
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
The default way Terraform orders resources is by reference. When a
resource block contains an expression that resolves to an attribute of
another resource, a data source, or a module output, Terraform records an
edge in the resource graph from the referenced address to the referencing
address. The operator does not write a depends_on. Terraform infers the
edge from the expression itself.
This lesson is the foundation of the next two: explicit depends_on
overrides what the inference produces, and the dependency graph is the
data structure that holds both kinds of edges.
A production scenario
A team has a VPC, three subnets, and a security group with ingress rules. The rules reference the VPC id for tagging:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "eu-west-2a"
}
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = aws_vpc.main.id
}
resource "aws_security_group_rule" "ingress_https" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.web.id
tags = {
VpcId = aws_vpc.main.id
}
}
The tags = { VpcId = aws_vpc.main.id } line is the interesting one. The
team uses the value only as a human-readable label in the AWS console.
They do not think of it as a dependency. Terraform does. The rule is
forced to wait for the VPC to finish applying before the rule is even
attempted. If the VPC creation fails, the rule does not run. The apply
graph sees an edge from aws_vpc.main to aws_security_group_rule.ingress_https.
The apply is correct. The apply is also slower than it needs to be. The edge is real even if the operator did not intend it.
How the inference works
Terraform parses the configuration and walks every expression. The walker records an edge whenever it sees:
- A resource address:
aws_vpc.main - A nested attribute:
aws_vpc.main.id,aws_subnet.a.cidr_block - A data source:
data.aws_ami.ubuntu.id - A module output:
module.network.vpc_id - A
for_eachorcountreference:aws_subnet.a[each.key].id - A splat expression:
aws_subnet.a[*].id - A
try()orlookup()over a referenced map - A
templatestring()interpolation:"${aws_vpc.main.id}-suffix"
The walker does not record an edge for:
- A variable:
var.region - A local value:
local.name - A literal:
"10.0.0.0/16" - A provider config:
aws.alias.eu.id - A path.module / path.root reference
- A terraform / terraform_remote_state reference
Variables, locals, and literals are evaluated before the graph is built. They have no graph edge because their value is known to Terraform at parse time.
Configuration (HCL)
|
v
+--------------------+
| Expression walker |
+--------------------+
|
v
+--------------------+
| Resource graph | (DAG: nodes are addresses, edges are dependencies)
+--------------------+
|
v
Topological sort + parallelism slicing
|
v
Apply order
An example graph
For the configuration above, terraform graph produces (abbreviated):
digraph {
compound = "true"
"aws_vpc.main" [label = "aws_vpc.main"]
"aws_subnet.a" [label = "aws_subnet.a"]
"aws_security_group.web" [label = "aws_security_group.web"]
"aws_security_group_rule.ingress_https" [label = "aws_security_group_rule.ingress_https"]
"aws_subnet.a" -> "aws_vpc.main"
"aws_security_group.web" -> "aws_vpc.main"
"aws_security_group_rule.ingress_https" -> "aws_security_group.web"
"aws_security_group_rule.ingress_https" -> "aws_vpc.main"
}
The last line is the incidental edge. The rule depends on the VPC, but only because of the tag. Removing the tag removes the edge.
The silent-misconfig risk
The risk is not that Terraform is wrong. Terraform records exactly what the expression says. The risk is that the operator writes a reference without realising it is a dependency.
Three common shapes of this risk:
1. Logging or labelling.
tags = {
SourceBucket = aws_s3_bucket.input.bucket
}
The tag is for human readers. The dependency is real.
2. Conditional expressions over referenced attributes.
count = aws_s3_bucket.input.versioning_enabled ? 1 : 0
The condition reads an attribute of the bucket. The bucket must exist before the count is evaluated. The edge is real.
3. Validation against a referenced attribute.
lifecycle {
precondition {
condition = aws_vpc.main.cidr_block == "10.0.0.0/16"
error_message = "VPC CIDR has drifted."
}
}
The lifecycle precondition reads the VPC. The resource cannot be planned until the VPC has been refreshed. The edge is real.
How to avoid the incidental edge
The fix is to separate “value I want to know” from “value that controls ordering.” Three patterns:
Pattern 1: compute the value in a local.
locals {
vpc_id_label = aws_vpc.main.id
}
resource "aws_security_group_rule" "ingress_https" {
# ...
tags = {
VpcId = local.vpc_id_label
}
}
This does not help. The local references the VPC; the resource references the local; the edge is still there. Locals do not break dependency chains; they only rename them.
Pattern 2: read the value at apply time, not at plan time.
Use a data source to read the VPC at apply time. The data source
refreshes during the apply and the resource can plan in parallel with
the VPC creation:
data "aws_vpc" "by_id" {
id = "vpc-0123456789abcdef0"
}
resource "aws_security_group_rule" "ingress_https" {
# ...
tags = {
VpcId = data.aws_vpc.by_id.id
}
}
The data source refreshes from the AWS API. The rule no longer needs the VPC to be in state.
Pattern 3: write the value to state and reference the state attribute of the data source instead.
This is the cleanest separation when the value is needed for display only and the dependency on the underlying resource is incidental.
The choice between patterns depends on whether the reference is to a managed resource (use a data source) or to an attribute the operator controls through Terraform (use the reference and accept the edge).
Failure modes
- Tag reference to a slow resource. A
tagsblock referencing a resource that takes minutes to create (RDS, large EC2 instance) serialises everything that touches the tag. The apply blocks on a value used for display only. - Reference inside
for_eachover a sibling.for_each = toset(aws_s3_bucket.input.tags["Names"])reads the tags of another bucket. The edge forces a serial apply even if the buckets are independent in every other way. - Reference through a module output. A module output that exposes a value from a deeply nested resource drags the whole nested chain into the consumer’s graph. Splitting the module is the fix.
- Reference that becomes null at plan time. A resource reads
aws_instance.web.public_dnswhich is(known after apply). The plan uses a placeholder. If the reference is in an argument that does not tolerate unknowns (e.g. acidr_block), the apply fails. - Reference inside
lifecycle.preconditionagainst an attribute that is(known after apply). The precondition cannot be evaluated; the plan fails withCondition cannot be evaluated at plan time. - Reference to a resource in another state file. Terraform does not
cross state boundaries. The reference is resolved at apply time via
a
terraform_remote_statedata source, not as an implicit edge.
How to validate
terraform validate
terraform plan -out=tfplan
terraform graph -type=plan | grep "ingress_https"
The grep filters the graph to the resources you care about. The
output shows every edge for that resource. Read the edges. For each
edge, decide whether the dependency is intentional. Remove the edges
that are not.
Performance implications
Each edge is a serialisation point. A configuration with N independent
resources and E edges runs in roughly ceil(N / -parallelism) + E
“waves.” A configuration with no edges runs in ceil(N / -parallelism)
waves. The difference can be the difference between a 30-second and a
30-minute apply.
For a 100-resource configuration, even 10 incidental edges add 10 serial waits. At 3 seconds per resource, that is 30 seconds of unnecessary latency on every apply.
What to do in production
- Run
terraform graphon every non-trivial change and read the output before merging. - Treat every edge as a question: “is this dependency real?”
- For display-only references, prefer data sources over managed resource references.
- If a managed resource reference is required and the edge is incidental, accept the cost or restructure the configuration so the reference does not exist.
- Document the intentional edges in the module’s README. Reviewers should be able to read the documentation and predict the graph.
Security implications
A reference does not grant or revoke permissions. The graph records ordering, not access. The risk is operational: an edge that an operator does not understand is an edge that can be removed by an unrelated change. A removed edge can cause an apply to fail (because the consumer ran before the producer) or to succeed with a stale value (because the consumer used a cached attribute). Both are bad in production. Inspect the graph before merging.
Verification
- Run
terraform validateand confirm the configuration is valid. - Run
terraform plan -out=tfplanand confirm the plan produces the expected set of changes. - Run
terraform graph -type=planand identify every edge for the resources you touched. - For each edge, confirm the dependency is intentional. Remove the edges that are not.
- Re-run
terraform planafter removing the incidental edges and confirm the apply order is shorter.
Knowledge check · 7 questions
Q1. What creates an implicit dependency in Terraform?
Q2. Terraform can plan a resource that references an attribute of another resource without knowing the referenced value until apply time.
Q3. A `tags` block references `aws_instance.web.id` only to record the value as a label. What is the operational risk?
Q4. Which of the following expressions create an implicit dependency? (Select all that apply.)
Q5. A `aws_security_group_rule` references `aws_vpc.main.id` in its `tags` block. The VPC is deleted outside Terraform. What happens on the next plan?
Q6. How does Terraform detect implicit dependencies when parsing the configuration?
Q7. A reference inside a `lifecycle.precondition` block creates an implicit dependency.
Passing score: 75%. Answers are checked in this browser.