Skip to content
Infrastructure as Code iac getting-started 4 min read

Declarative vs Imperative

Infrastructure as Code tools fall into two broad paradigms: declarative and imperative. The distinction shapes how you author configuration, how the tool reasons about change, and how predictable your deployments are at scale. Understanding it is the difference between describing what you want and scripting how to get there — and it explains why tools like Terraform have become the default for managing cloud infrastructure.

The two paradigms

In the imperative model, you write an ordered sequence of commands that mutate the system: create this, then attach that, then start the other. The author is responsible for the control flow — checking whether something already exists, branching, retrying, and cleaning up. The tool runs your steps top to bottom and trusts that they produce a correct result.

In the declarative model, you describe the desired end state of your infrastructure. You never write “create” or “delete” — you simply state that a resource should exist with a given shape. The tool inspects reality, computes the difference between current and desired state, and works out the minimal set of operations needed to converge. Terraform, OpenTofu, AWS CloudFormation, and Pulumi (in its resource model) are declarative; shell scripts, the raw AWS CLI, and Ansible’s procedural playbooks lean imperative.

A side-by-side example

Suppose you want an S3 bucket with versioning enabled. Imperatively, with the AWS CLI, you script every step and guard against the bucket already existing:

#!/usr/bin/env bash
set -euo pipefail

BUCKET="devcraftly-artifacts"

if ! aws s3api head-bucket --bucket "$BUCKET" 2>/dev/null; then
  aws s3api create-bucket \
    --bucket "$BUCKET" \
    --region us-east-1
fi

aws s3api put-bucket-versioning \
  --bucket "$BUCKET" \
  --versioning-configuration Status=Enabled

You own the existence check, the ordering, and the error handling. Run it twice and you must hope each command is safe to repeat.

Declaratively, with Terraform, you describe the result and let the engine figure out the rest:

resource "aws_s3_bucket" "artifacts" {
  bucket = "devcraftly-artifacts"
}

resource "aws_s3_bucket_versioning" "artifacts" {
  bucket = aws_s3_bucket.artifacts.id

  versioning_configuration {
    status = "Enabled"
  }
}

There is no if, no ordering logic, no create-or-update branch. The reference aws_s3_bucket.artifacts.id implicitly tells Terraform the bucket must exist first. On the first run, both resources are created:

Output:

Terraform will perform the following actions:

  # aws_s3_bucket.artifacts will be created
  + resource "aws_s3_bucket" "artifacts" {
      + bucket = "devcraftly-artifacts"
      + id     = (known after apply)
    }

  # aws_s3_bucket_versioning.artifacts will be created
  + resource "aws_s3_bucket_versioning" "artifacts" {
      + bucket = (known after apply)
    }

Plan: 2 to add, 0 to change, 0 to destroy.

Run terraform apply again with no changes and the tool reports the desired state already matches reality:

Output:

No changes. Your infrastructure matches the configuration.

Idempotency and convergence

The declarative example above is idempotent: applying it any number of times yields the same result. That property falls out of the design — Terraform compares desired state against recorded state and acts only on the diff. The imperative script can be made idempotent, but only because you hand-wrote the head-bucket guard. Every new resource adds another check you must remember, and the burden grows with the system.

Declarative tools also handle convergence automatically. If someone manually disables versioning in the console, the next plan detects drift and proposes restoring it:

Output:

  # aws_s3_bucket_versioning.artifacts will be updated in-place
  ~ resource "aws_s3_bucket_versioning" "artifacts" {
      ~ versioning_configuration {
          ~ status = "Suspended" -> "Enabled"
        }
    }

Plan: 0 to add, 1 to change, 0 to destroy.

Note: OpenTofu, the community fork of Terraform, is a drop-in replacement here — the same HCL, the same plan/apply/state model, and the same idempotency guarantees. Swap the terraform binary for tofu and the examples above behave identically.

Comparison

AspectDeclarative (Terraform / CloudFormation)Imperative (scripts / Ansible procedural)
You specifyThe desired end stateThe exact steps to reach it
OrderingInferred from dependenciesAuthor writes it explicitly
IdempotencyBuilt inMust be hand-coded per step
Drift detectionAutomatic via state comparisonNone — script re-runs blindly
Change previewterraform plan diff before applyUsually none
Mental model”What should exist""What to do, in order”
Scales to large systemsWell — the engine does the diffPoorly — complexity grows linearly

Warning: Mixing paradigms invites drift. If a Terraform-managed resource is also poked by an imperative script, the next plan will fight to revert those out-of-band changes. Pick one source of truth per resource.

When imperative still fits

Declarative wins for provisioning, but imperative thinking suits genuinely ordered, one-shot tasks: database migrations, blue/green cutover sequences, or ad-hoc remediation. Even Terraform exposes an imperative escape hatch — null_resource with provisioner blocks or the terraform_data resource — for the rare step that has no declarative API. Reach for it sparingly; it forfeits the diff and idempotency guarantees that make declarative IaC valuable.

Best practices

  • Default to declarative tooling for anything long-lived; reserve imperative scripts for ordered, run-once operations.
  • Treat your configuration as the single source of truth and avoid out-of-band changes that cause drift.
  • Always run terraform plan (or tofu plan) and read the diff before applying.
  • Let the engine infer ordering through resource references rather than depends_on unless a dependency is truly implicit.
  • Keep provisioner and null_resource usage to a minimum — they reintroduce imperative fragility.
  • Write small, composable resources so the computed diff stays readable and reviewable.
Last updated June 14, 2026
Was this helpful?