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 thesudogroup.”
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 deployProblems:
- What happens if you run this twice?
useradd deployfails the second time because the user already exists. The script errors out. You have to add|| trueor 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
deployin thesudogroup.”
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: yesRun 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:
- Idempotent by design. Run it a thousand times, end state is the same. No “did I already apply this patch?” anxiety.
- Drift correction. The declaration is the source of truth. The tool re-converges reality to it on every run. Manual changes are fixed automatically.
- 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;
Netmikoscripts 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