Declarative #

The word “declarative” comes up often when discussing Terraform, but most explanations stop at “you define what you want, not how to create it.” That explanation is correct, but not enough to change how you think when writing configuration. The declarative approach isn’t just a philosophy — it has very concrete technical implications for how Terraform calculates changes, arranges dependencies, executes resources, and handles errors. Understanding the mechanics behind the declarative approach will make you write cleaner, more efficient HCL with fewer bugs.

Declarative vs Imperative: The Fundamental Difference #

Before diving into Terraform’s mechanics, it’s important to understand the fundamental difference between the declarative and imperative approaches. This difference isn’t just about syntax — it changes how you model problems.

In the imperative approach, you write a sequence of steps to reach a desired state. You have to know the initial state, the final state, and every step in between. If the initial state differs from what you assumed, the script can fail or produce inconsistent results.

In the declarative approach, you only define the end state. You don’t care about the initial state — you only care about what the final state should look like. The tool is responsible for figuring out how to reach that state from whatever state exists right now.

// ANTI-PATTERN: Thinking imperatively in Terraform
// (trying to control ordering that isn't actually needed)

resource "null_resource" "wait_for_vpc" {
  depends_on = [aws_vpc.main]

  provisioner "local-exec" {
    command = "sleep 10"  // Waiting for the VPC to be "ready"  not reliable
  }
}

resource "aws_subnet" "public" {
  depends_on = [null_resource.wait_for_vpc]  // Unnecessary dependency
  vpc_id     = aws_vpc.main.id
  cidr_block = "10.0.1.0/24"
}

// CORRECT: Declarative  let Terraform determine the order

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id  // This reference is enough
  cidr_block = "10.0.1.0/24"    // to express the dependency
}

The following table summarizes the main differences:

AspectImperativeDeclarative
InputSequence of stepsEnd state
State awarenessMust know the initial stateDoesn’t care about the initial state
IdempotencyMust be implemented manuallyBuilt-in naturally
Error handlingTry-catch at every stepAutomatic rollback to a consistent state
ParallelizationManual (must be arranged)Automatic (from the dependency graph)
Example toolsAnsible, Shell scripts, PulumiTerraform, CloudFormation, Kustomize

How Terraform Calculates Changes #

Every time you run terraform plan, Terraform performs three very specific steps. This process is what makes the declarative approach work technically.

Step 1: Read the configuration. Terraform reads all .tf files in the working directory and builds a representation of the desired state — the end state you want.

Step 2: Read the state. Terraform reads the state file (terraform.tfstate) containing a representation of the current state — the infrastructure’s condition based on the last operations Terraform performed.

Step 3: Calculate the delta. Terraform compares the desired state and current state, then produces an execution plan containing the list of operations (create, update, delete) needed to reach the end state.

flowchart TD
    A["terraform plan"] --> B["Read configuration\n(.tf files)"]
    A --> C["Read state\n(.tfstate)"]
    B --> D["Desired State"]
    C --> E["Current State"]
    D --> F["Calculate Delta"]
    E --> F
    F --> G{"Any\ndifferences?"}
    G -->|"Yes"| H["Generate Execution Plan"]
    G -->|"No"| I["No changes.\nInfrastructure up to date."]
    H --> J["Create\n(new resource)"]
    H --> K["Update\n(changed resource)"]
    H --> L["Delete\n(removed resource)"]

    style A fill:#e3f2fd,stroke:#1565c0
    style I fill:#e8f5e9,stroke:#2e7d32
    style J fill:#e8f5e9,stroke:#2e7d32
    style K fill:#fff3e0,stroke:#e65100
    style L fill:#ffebee,stroke:#c62828

This process produces three types of operations:

  • Create — the resource is in the configuration but not in the state. That means the resource was never created or was just added to the configuration.
  • Update in-place — the resource is in both the configuration and the state, but its attributes changed. Terraform modifies the existing resource without deleting it.
  • Destroy — the resource is in the state but not in the configuration. That means the resource was removed from the configuration and needs to be deleted from the infrastructure.
# Example: a configuration that was previously applied
resource "aws_instance" "web" {
  ami           = "ami-0abcdef1234567890"
  instance_type = "t3.micro"  # ← changed from t3.small to t3.micro
  tags = {
    Name = "web-server"
  }
}

# terraform plan will show:
# aws_instance.web will be updated in-place
# ~ {
#   ~ instance_type = "t3.small" -> "t3.micro"
# }

# Terraform knows it just needs to change instance_type,
# no need to delete and recreate the instance

Idempotency: The Natural Consequence of the Declarative Approach #

Idempotency means running the same operation multiple times produces the same result. In the context of infrastructure, this means running terraform apply ten times with the same configuration only makes changes on the first apply — the following nine applies do nothing.

This isn’t a feature implemented manually — it’s a natural consequence of the declarative approach. Because Terraform always calculates the delta between desired state and current state, and the delta is zero when both are equal, there’s nothing to do.

# First apply — creates resources
$ terraform apply
aws_vpc.main: Creating...
aws_subnet.public: Creating...
aws_instance.web: Creating...
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

# Second apply — no changes
$ terraform apply
No changes. Your infrastructure matches the configuration.
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

# Tenth apply — still no changes
$ terraform apply
No changes. Your infrastructure matches the configuration.
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

This idempotency is crucial for CI/CD pipelines. You can run terraform apply on every deploy without worrying about disturbing existing resources. If there are no changes, nothing happens.

stateDiagram-v2
    [*] --> DesiredState: Write configuration
    DesiredState --> DeltaCalculation: terraform plan
    DeltaCalculation --> HasChanges: Delta exists
    DeltaCalculation --> NoChanges: No delta
    HasChanges --> Apply: terraform apply
    Apply --> CurrentState: Resources modified
    CurrentState --> DeltaCalculation: terraform plan (again)
    NoChanges --> [*]: Infrastructure up to date
    CurrentState --> [*]: Infrastructure up to date

Automatic Dependency Graph #

A major advantage of the declarative approach is that Terraform can calculate the dependency graph automatically. You don’t need to specify “create the VPC first, then the subnet, then the instance” — Terraform infers it from references between resources.

When you write aws_vpc.main.id inside a subnet, you’re indirectly telling Terraform that the subnet depends on the VPC. Terraform collects all these references and builds a Directed Acyclic Graph (DAG) that determines the execution order.

# You write resources in any order — Terraform determines the execution order

resource "aws_instance" "web" {
  ami       = "ami-0abcdef1234567890"
  subnet_id = aws_subnet.public.id  # ← depends on the subnet
  vpc_security_group_ids = [aws_security_group.web.id]  # ← depends on the SG
}

resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id  # ← depends on the VPC
  cidr_block = "10.0.1.0/24"
}

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}

resource "aws_security_group" "web" {
  vpc_id = aws_vpc.main.id  # ← depends on the VPC
  name   = "web-sg"
}

# Terraform calculates the dependency graph:
# Level 0: aws_vpc.main (depends on nothing)
# Level 1: aws_subnet.public, aws_security_group.web (depend on VPC)
# Level 2: aws_instance.web (depends on subnet + SG)
#
# Level 0 executes first, then level 1 (in parallel), then level 2
flowchart TD
    VPC["aws_vpc.main"] --> SUBNET["aws_subnet.public"]
    VPC --> SG["aws_security_group.web"]
    SUBNET --> EC2["aws_instance.web"]
    SG --> EC2

    style VPC fill:#e3f2fd,stroke:#1565c0
    style SUBNET fill:#e8f5e9,stroke:#2e7d32
    style SG fill:#e8f5e9,stroke:#2e7d32
    style EC2 fill:#fff3e0,stroke:#e65100

You can see the dependency graph Terraform calculated with the terraform graph command. Its output is in DOT format, which can be visualized with Graphviz.

# View the dependency graph in DOT format
$ terraform graph

# Visualize as an SVG (requires Graphviz installed)
$ terraform graph | dot -Tsvg > graph.svg

Automatic Parallelization #

Resources that don’t depend on each other are executed in parallel. In the example above, aws_subnet.public and aws_security_group.web can be created simultaneously because both only depend on the VPC — neither depends on the other.

# Example: 5 resources that can be executed in parallel

resource "aws_s3_bucket" "logs" {
  bucket = "app-logs-2024"
}

resource "aws_s3_bucket" "assets" {
  bucket = "app-assets-2024"
}

resource "aws_s3_bucket" "backups" {
  bucket = "app-backups-2024"
}

# These three buckets don't depend on each other
# → Terraform creates them in parallel
# → Faster than sequential creation

# You can control the level of parallelism:
# $ terraform apply -parallelism=5
# (default: 10 parallel operations)

This parallelization is very noticeable in large infrastructures. Creating 50 security groups that don’t depend on each other will be far faster than a sequential script creating them one by one.

depends_on for Explicit Dependencies #

Sometimes a dependency can’t be inferred from a direct reference — for example, when a resource depends on a side effect of another resource. For these cases, Terraform provides depends_on as an explicit dependency mechanism.

# Case: an EKS node group needs an IAM role already attached to a policy
# This dependency isn't visible from ordinary resource references

resource "aws_iam_role" "worker" {
  name = "eks-worker-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "worker_cni" {
  role       = aws_iam_role.worker.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
}

resource "aws_iam_role_policy_attachment" "worker_node" {
  role       = aws_iam_role.worker.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.worker.arn
  subnet_ids      = aws_subnet.private[*].id

  # depends_on is needed because the node group needs the role
  # ALREADY attached to the policy — not just the role itself
  depends_on = [
    aws_iam_role_policy_attachment.worker_cni,
    aws_iam_role_policy_attachment.worker_node,
  ]
}
Use depends_on only as a last resort. If the dependency can be expressed through a direct reference (like aws_vpc.main.id), use that reference. depends_on makes dependencies coarser — everything listed inside depends_on must finish first, even if you only need one specific attribute.

Why Declarative Fits Infrastructure Better #

The declarative approach isn’t right for every domain — there are areas where imperative is more natural (like deployment pipelines or server configuration). But for cloud infrastructure, declarative has several very significant advantages.

Guaranteed consistency. Because you define the end state, the resulting infrastructure is always consistent with the configuration. There’s no “hidden state” created by out-of-order steps.

Easy recovery. When a resource is deleted manually in the cloud console, just run terraform apply and the resource gets recreated. You don’t need to know what was deleted or how to recreate it — just run apply and Terraform calculates what needs to be done.

Varied initial states are handled automatically. Imperative scripts can fail when the initial state doesn’t match assumptions. Terraform doesn’t have this problem — it always calculates from the current state to the desired end state, whatever the initial state is.

# Example: A resource deleted manually in the AWS Console

# Before: 3 EC2 instances managed by Terraform
# Someone deletes 1 instance from the Console

# Imperative script: ERROR
# "Instance i-abc123 not found" — the script doesn't know how to recover

# Terraform: "1 instance missing, I'll recreate it"
# $ terraform plan
# - aws_instance.web[2] (resource deposed) will be created
# Plan: 1 to add, 0 to change, 0 to destroy.

Declarative vs Imperative in a Real Context #

To clarify the difference, here’s the same task — creating a VPC with a subnet — in both approaches.

# IMPERATIVE (AWS CLI):
# You must determine the order, handle errors, and check conditions

# Step 1: Create the VPC
VPC_ID=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
  --query 'Vpc.VpcId' --output text)

# Step 2: Wait for the VPC to be available
aws ec2 wait vpc-available --vpc-ids $VPC_ID

# Step 3: Create the subnet
SUBNET_ID=$(aws ec2 create-subnet --vpc-id $VPC_ID \
  --cidr-block 10.0.1.0/24 \
  --query 'Subnet.SubnetId' --output text)

# Step 4: Create the security group
SG_ID=$(aws ec2 create-security-group \
  --group-name web-sg --description "Web SG" \
  --vpc-id $VPC_ID \
  --query 'GroupId' --output text)

# Step 5: Add a rule to the SG
aws ec2 authorize-security-group-ingress \
  --group-id $SG_ID --protocol tcp --port 443 --cidr 0.0.0.0/0

# Problems:
# - If step 3 fails, steps 4 and 5 don't run
# - Running again creates a new VPC (duplicate)
# - No automatic cleanup if it fails midway
# - If the VPC already exists from a previous run, the script errors
# DECLARATIVE (Terraform):
# You only define the end state

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_security_group" "web" {
  vpc_id = aws_vpc.main.id
  name   = "web-sg"

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# Advantages:
# - Order determined automatically from the dependency graph
# - Idempotent: applying repeatedly gives the same result
# - Automatic rollback on error
# - If a resource already exists, it isn't recreated
# - Can be destroyed with terraform destroy

Challenges of the Declarative Approach #

The declarative approach isn’t without challenges. There are several situations where you need to work harder to achieve the desired result.

Strict sequential operations. If you need operations that truly must be sequential (for example: create the database, wait for it to be ready, run migrations, then create the app server), declarative can feel rigid. Terraform has depends_on and lifecycle hooks, but they’re not as flexible as sequential scripts.

Provisioning that needs interaction. Running commands on a server after it’s created (like installing software or running setup scripts) isn’t Terraform’s strength. Provisioners like remote-exec exist, but are considered an anti-pattern for production.

Complex conditional logic. HCL has count and for_each for conditional resources, but they’re not as expressive as general-purpose programming languages. Complex logic is sometimes easier to implement in Pulumi (imperative) than in Terraform (declarative).

# Challenge: conditional resources in Terraform

# You want a NAT Gateway only in production
# Use count with a conditional expression

resource "aws_nat_gateway" "main" {
  count = var.environment == "production" ? 1 : 0
  # count = 0 → the resource isn't created
  # count = 1 → the resource is created

  allocation_id = aws_eip.nat[0].id
  subnet_id     = aws_subnet.public[0].id
}

# This works, but can be confusing
# when references need to handle count = 0
# For example: aws_nat_gateway.main[0].id can error
# when count = 0
flowchart TD
    A["Situation"] --> B{"Need strict\nsequential operations?"}
    B -->|"Yes"| C["Consider\nprovisioners or\nnull_resource"]
    B -->|"No"| D{"Need complex\nconditionals?"}
    D -->|"Yes"| E["Use count,\nfor_each,\nor conditionals"]
    D -->|"No"| F{"Need interaction\nwith the server?"}
    F -->|"Yes"| G["Use Ansible/\nuser_data/\nAMI baking"]
    F -->|"No"| H["Declarative Terraform\nis enough for this case"]

    style C fill:#fff3e0,stroke:#e65100
    style E fill:#e3f2fd,stroke:#1565c0
    style G fill:#fff3e0,stroke:#e65100
    style H fill:#e8f5e9,stroke:#2e7d32

Summary #

  • Declarative = define the end state — you state what you want, Terraform determines how to get there from whatever state exists now.
  • Terraform calculates the delta between the configuration (desired state) and the state (current state) — three steps: read config, read state, compare them.
  • Idempotency is a natural consequence — running terraform apply repeatedly with the same configuration only makes changes the first time.
  • The dependency graph is calculated automatically from references between resources — resources at the same level execute in parallel for efficiency.
  • depends_on for edge cases — use it only when a dependency can’t be inferred from a direct reference, because it makes dependencies coarser.
  • Automatic recovery — if a resource is deleted manually, just run terraform apply to recreate it.
  • Declarative challenges — strict sequential operations, provisioning that needs interaction, and complex conditional logic require special approaches.

← Previous: When to Use Terraform?   Next: Provider →

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