Execution Plan #

When you run terraform plan, Terraform doesn’t just make a random list of changes. Behind the scenes, it builds a dependency graph — a data structure that determines the exact execution order, which resources can run in parallel, and which resources must wait for others to finish first. Understanding how the execution plan is built helps you write more efficient configurations and debug problems that aren’t obvious on the surface.

What Is a Dependency Graph #

A dependency graph is the data structure Terraform builds from your configuration. Every resource is a node in the graph, and every reference between resources is an edge (a dependency direction). Terraform automatically infers dependencies from references — you don’t need to specify the execution order manually.

flowchart TD
    A["aws_vpc.main\n(no dependencies,\nexecuted first)"] --> B["aws_subnet.public\n(requires the VPC)"]
    A --> C["aws_internet_gateway.main\n(requires the VPC)"]
    A --> D["aws_route_table.public\n(requires the VPC)"]
    B --> E["aws_instance.web\n(requires the subnet)"]

    style A fill:#e3f2fd,stroke:#1565c0
    style B fill:#e8f5e9,stroke:#2e7d32
    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#e8f5e9,stroke:#2e7d32
    style E fill:#fff3e0,stroke:#e65100
# The configuration producing the graph above:
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id       # ← edge: subnet → VPC
  cidr_block = "10.0.1.0/24"
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id           # ← edge: IGW → VPC
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id           # ← edge: route table → VPC
}

resource "aws_instance" "web" {
  subnet_id = aws_subnet.public.id   # ← edge: instance → subnet
  ami       = var.ami_id
}
Dependency LevelResourceDepends OnCan Run Parallel With
Level 0 (root)aws_vpc.mainNothing
Level 1aws_subnet.publicVPCinternet_gateway, route_table
Level 1aws_internet_gateway.mainVPCsubnet, route_table
Level 1aws_route_table.publicVPCsubnet, internet_gateway
Level 2aws_instance.webSubnetNothing in this example
How Terraform infers dependencies: Every time you write aws_vpc.main.id or aws_subnet.public.id inside a resource block, Terraform automatically detects that the resource depends on the owner of that attribute. No manual dependency declaration needed.

Parallelism in the Execution Plan #

Terraform automatically executes resources in parallel as long as there are no dependencies between them. This makes Terraform far more efficient than sequential scripts.

gantt
    title Sequential vs Parallel Execution Comparison
    dateFormat X
    axisFormat %s

    section Sequential (Bash)
    VPC           :a1, 0, 2s
    Subnet        :a2, 2, 4s
    IGW           :a3, 4, 6s
    Route Table   :a4, 6, 8s
    Instance      :a5, 8, 10s

    section Parallel (Terraform)
    VPC           :b1, 0, 2s
    Subnet        :b2, 2, 4s
    IGW           :b3, 2, 4s
    Route Table   :b4, 2, 4s
    Instance      :b5, 4, 6s
SEQUENTIAL (bash script):
  VPC:    ████ (2s)
  Subnet: ░░░░████ (4s after VPC)
  IGW:    ░░░░░░░░████ (6s after subnet)
  RT:     ░░░░░░░░░░░░████ (8s after IGW)
  Instance:░░░░░░░░░░░░░░░░████ (10s after RT)
  Total:  ~14 seconds (everything sequential)

PARALLEL (Terraform):
  VPC:     ████ (2s)
  Subnet:  ░░░░████ (starts after VPC)
  IGW:     ░░░░████ (starts together with the subnet)
  RT:      ░░░░████ (starts together with the subnet and IGW)
  Instance:░░░░░░░░████ (waits for the subnet to finish)
  Total:   ~6 seconds (VPC → parallel → instance)
AspectSequential (Script)Parallel (Terraform)
Total time (example)~14 seconds~6 seconds
Independent resourcesRun one after anotherRun simultaneously
Default concurrency1 by 110 at once
EfficiencyLowHigh
# Adjust parallelism as needed
terraform plan                              # Default: 10 parallel
terraform apply -parallelism=5              # Reduce if rate-limited
terraform apply -parallelism=20             # Increase for large infra

Reading the Execution Plan in Detail #

The terraform plan output shows the order Terraform will follow, but doesn’t explicitly show parallelism. To see the dependency graph visually, use terraform graph.

flowchart TD
    A["terraform graph"] --> B["DOT format (Graphviz)"]
    B --> C["Render to SVG/PNG\n(dot -Tsvg / dot -Tpng)"]
    B --> D["Render to JSON\n(terraform show -json)"]

    E["terraform graph -plan=tfplan"] --> F["Graph specific\nto a particular\nplan"]

    style A fill:#e3f2fd,stroke:#1565c0
    style E fill:#e3f2fd,stroke:#1565c0
# Generate the dependency graph in DOT format (Graphviz)
terraform graph

# Output (snippet):
# digraph {
#   compound = "true"
#   newrank = "true"
#   "aws_vpc.main" -> "aws_subnet.public"
#   "aws_vpc.main" -> "aws_internet_gateway.main"
#   "aws_subnet.public" -> "aws_instance.web"
# }

# Render to an image (requires Graphviz installed: brew install graphviz)
terraform graph | dot -Tsvg > graph.svg
terraform graph | dot -Tpng > graph.png

# For a specific plan (not the current configuration)
terraform graph -plan=tfplan

Explicit Dependencies with depends_on #

Terraform infers dependencies from direct references (aws_vpc.main.id). But there are cases where a dependency isn’t visible from references — for example, a dependency on a side effect.

flowchart TD
    A["Dependency between resources"] --> B{"Can it be inferred\nfrom a reference?"}

    B -->|"Yes: vpc_id = aws_vpc.main.id"| C["✅ Implicit dependency\nAutomatically detected\nNo depends_on needed"]
    B -->|"No: side effect\ne.g. IAM policy\nalready attached"| D["⚠️ Explicit dependency\nNeeds depends_on"]

    D --> E[""depends_on = [\n  aws_iam_role_policy_attachment.node_policy\n""]

    C --> F["Maximum parallelism\nEfficient"]
    E --> G["Reduces parallelism\nResources wait\nunnecessarily"]

    style C fill:#e8f5e9,stroke:#2e7d32
    style D fill:#fff3e0,stroke:#e65100
    style F fill:#e8f5e9,stroke:#2e7d32
    style G fill:#ffebee,stroke:#c62828

When depends_on Is Needed #

# CASE: An EKS Node Group needs an IAM policy already attached to the role
# This dependency CANNOT be inferred from resource references alone
# because the node_group only references the role ARN, not the policy attachment

resource "aws_iam_role" "node" {
  name = "eks-node-role"
  # ...
}

resource "aws_iam_role_policy_attachment" "node_policy" {
  role       = aws_iam_role.node.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
}

resource "aws_eks_node_group" "workers" {
  cluster_name    = aws_eks_cluster.main.name
  node_role_arn   = aws_iam_role.node.arn

  # ✅ depends_on is needed here because:
  # the node_group references the role ARN (not the policy attachment)
  # Terraform doesn't know the node_group needs the policy already attached
  depends_on = [
    aws_iam_role_policy_attachment.node_policy
  ]
}
Casedepends_on?Explanation
Resource B references resource_a.id❌ Not neededThe dependency is already implicit from the reference
Resource B needs a side effect from A✅ NeededE.g. an IAM policy must be attached first
Resource B needs A to finish first❌ Check firstIt may be implicit from a reference

Anti-Pattern: Excessive depends_on #

# ❌ ANTI-PATTERN: Unnecessary depends_on
resource "aws_subnet" "public" {
  depends_on = [aws_vpc.main]  # ✗ Not needed!
  vpc_id     = aws_vpc.main.id # This reference is already enough
  cidr_block = "10.0.1.0/24"
}

# ✅ CORRECT: Let the reference determine the dependency automatically
resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id  # ✓ The dependency is already implied
  cidr_block = "10.0.1.0/24"
}
flowchart TD
    A["Excessive depends_on"] --> B["Resources wait for\nresources that could\nactually run in parallel"]
    B --> C["Reduced parallelism\nSlower applies"]
    B --> D["Dependency graph\nmore complex\nthan needed"]

    E["Best practices"] --> F["Use direct references\nfor implicit dependencies"]
    E --> G["Use depends_on\nonly for side effects"]
    E --> H["Document the reason\nfor every depends_on"]

    style A fill:#ffebee,stroke:#c62828
    style C fill:#ffebee,stroke:#c62828
    style E fill:#e8f5e9,stroke:#2e7d32

The Execution Plan in JSON Format #

For integration with external tooling (CI/CD, policy checkers, notifications), the execution plan can be exported in JSON format. This enables programmatic inspection of the planned changes.

flowchart TD
    A["terraform plan\n-out=tfplan"] --> B["terraform show\n-json tfplan > plan.json"]
    B --> C["Analyze with jq\nor custom scripts"]
    B --> D["Policy checks\n(OPA, Sentinel)"]
    B --> E["Notifications\nSlack, Teams, etc."]
    B --> F["Approval gates\nCI/CD pipeline"]

    C --> G["Check the destroy count\nCheck resource types\nCheck blast radius"]
    D --> H["Validate compliance\nbefore applying"]
    E --> I["The team knows\nwhat will change"]
    F --> J["Manual approval\nbefore applying"]

    style B fill:#e3f2fd,stroke:#1565c0
    style G fill:#e8f5e9,stroke:#2e7d32
    style H fill:#e8f5e9,stroke:#2e7d32
# Generate a plan and export to JSON
terraform plan -out=tfplan
terraform show -json tfplan > plan.json

# Plan JSON structure (snippet):
# {
#   "format_version": "1.2",
#   "resource_changes": [
#     {
#       "address": "aws_instance.web",
#       "change": {
#         "actions": ["create"],
#         "before": null,
#         "after": {
#           "ami": "ami-0abcdef1234567890",
#           "instance_type": "t3.micro"
#         }
#       }
#     }
#   ]
# }

# Example: Check if any resources will be destroyed
terraform show -json tfplan | \
  jq '[.resource_changes[] | select(.change.actions[] == "delete")] | length'
# Output: 0 (nothing will be deleted)

# Example: Count the resources that will change
terraform show -json tfplan | \
  jq '[.resource_changes[] | select(.change.actions[] != "no-op")] | length'
# Output: 5 (5 resources will change)

# Example: List all resources that will be created
terraform show -json tfplan | \
  jq -r '.resource_changes[] | select(.change.actions[] == "create") | .address'
# Output:
# aws_vpc.main
# aws_subnet.public
# aws_instance.web
jq QueryUse CaseBest For
select(.actions[] == "delete")Detect resources to be deletedSafety check before apply
select(.actions[] != "no-op")Count total changesBlast radius assessment
select(.actions[] == "create")List new resourcesReview new resources
select(.actions[] == "update")List changed resourcesReview changes

Summary #

  • The dependency graph is built automatically from references between resources — you don’t need to specify the execution order manually.
  • Independent resources execute in parallel — Terraform runs up to 10 operations simultaneously by default for maximum efficiency.
  • terraform graph produces a visualization of the dependency graph — useful for understanding complex configurations. Render it to SVG/PNG with Graphviz.
  • depends_on is for side-effect dependencies — use it only when a dependency can’t be inferred from a direct reference (e.g. IAM policy attachments).
  • Don’t overuse depends_on — every explicit depends_on reduces parallelism and can slow down applies.
  • Export the plan to JSON (terraform show -json tfplan) for CI/CD integration — it enables programmatic checks like “no resources will be destroyed” or “blast radius assessment”.

← Previous: Destroy   Next: Idempotency →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact