Declarative vs Imperative Automation

The single most important distinction in modern automation. Declarative tools describe the desired state; imperative tools describe the steps. Getting this right shapes how you think about everything else.

The two styles, side by side

Imperative

“Install nginx. Start it. Enable it at boot. Create a user named deploy. Add deploy to the sudo group.”

A recipe of steps. Run the steps in order. If a step fails, you’re in an indeterminate state.

#!/bin/bash
apt-get update
apt-get install -y nginx
systemctl start nginx
systemctl enable nginx
useradd deploy
usermod -aG sudo deploy

Problems:

  • What happens if you run this twice? useradd deploy fails the second time because the user already exists. The script errors out. You have to add || true or conditionals everywhere.
  • What if nginx is already installed but stopped? Script works. Fine.
  • What if nginx is installed but at a wrong version? Script does nothing — the state is wrong but the script says “done.”

You have to hand-code every check. Most imperative scripts drift from reality over time.

Declarative

“This host should have nginx installed and running, and a user deploy in the sudo group.”

A description of the end state. A tool figures out what’s already true and what needs to change.

# Ansible
- name: nginx package installed
  apt:
    name: nginx
    state: present
 
- name: nginx running and enabled at boot
  service:
    name: nginx
    state: started
    enabled: yes
 
- name: deploy user exists and is in sudo
  user:
    name: deploy
    groups: sudo
    append: yes

Run it once: everything is provisioned. Run it twice: nothing happens — the tool sees the state already matches and skips. Run it after someone changed config manually: the tool fixes the drift.

This self-healing property is called Idempotence — the same input always produces the same end state, no matter how many times you apply it.

Why declarative won

Three reasons declarative is now the default for real automation:

  1. Idempotent by design. Run it a thousand times, end state is the same. No “did I already apply this patch?” anxiety.
  2. Drift correction. The declaration is the source of truth. The tool re-converges reality to it on every run. Manual changes are fixed automatically.
  3. Diffable / reviewable. Your declaration is a file; changes go through pull requests; history is tracked in git. You can diff “what we want today” against “what we wanted last week.”

How the tools split

Mostly declarative

  • Terraform / OpenTofu — cloud/infra resources
  • Kubernetes manifests — “I want 3 replicas of this pod”
  • Helm charts
  • AWS CloudFormation / Azure Bicep
  • Puppet
  • Pulumi (declarative state, imperative code to generate it)
  • SQL schema migrations (partially)

Mostly imperative

  • Bash scripts
  • Windows batch / PowerShell scripts
  • Python/Go scripts that call cloud APIs
  • Legacy runbooks

Hybrid — declarative with imperative escape hatches

  • Ansible — modules are designed to be idempotent, but you can drop down to raw shell commands when needed
  • Chef — recipes look imperative but individual resources are declarative
  • Salt — states are declarative, “execution modules” are imperative

When to use imperative anyway

Declarative is the default, but imperative has its place:

  • Bootstrap / one-shot provisioning — the very first time a host exists, something imperative usually runs to install the declarative agent
  • Procedural migrations — “first drop the old column, then rename the new one, then …”
  • Event responses — “when this alert fires, run this script”
  • Throwaway scripts — a bash one-liner to fix today’s incident doesn’t need Terraform

Rule of thumb: if a task is recurring and needs to reach a steady state, choose declarative. If it’s one-off or a sequence of events, imperative is fine.

The abstraction gap

Declarative is more restrictive. You can’t say “migrate this database by running these three SQL statements in order” in Terraform. You describe “the database should have this schema” — and the tool figures out the migration. When the tool can’t figure it out, you drop to imperative.

This gap is where most tooling failures happen. Terraform can’t safely evolve stateful things like databases; Ansible can’t always converge tricky multi-step orchestrations. Every mature Ops toolkit has a mix:

  • Terraform for infrastructure
  • Ansible for config management
  • Custom scripts / migrations for the imperative bits

In network automation

Same split applies:

  • Declarative network config — NETCONF / YANG / Nornir / Ansible network modules; you say “this is the desired config for interface Gi0/0”
  • Imperative network config — SSH into the device and type commands; Netmiko scripts that run a list of show/config commands

Modern network automation (Cisco NSO, Arista CloudVision, Juniper Apstra) leans hard on declarative because networks are stateful and drift is common.

Mental model check

Ask yourself: “if I run this twice, what happens?”

  • Crashes the second time → imperative, not idempotent, potentially buggy
  • Silently does nothing → declarative or idempotent imperative — good
  • Fixes drift that happened between runs → declarative, self-healing — best

See also