The question this answers
Should I describe the infrastructure I want, or the steps that produce it — and when is the sequence actually the better model?
The same definition must be safe to run ten times: on an empty account it builds everything, on a converged account it does nothing, and on a half-built one it finishes the job without duplicating what already exists.
Convergence: a run computes the difference between what is described and what exists, so the result depends on the target state rather than on how many times the command was run.
Two models of the same outcome
An imperative definition is a list of actions: create the network, then the subnet, then the instance. Run it twice and you get two instances, unless every step was written to check first — and "written to check first" is the entire hidden cost of the imperative model. Idempotency is not a property of the script; it is work the author has to do, per step, forever.
A declarative definition is a list of facts that should be true: there is a network with this range, a subnet in it, and one instance in the subnet. The tool is responsible for the actions. Run it twice and the second run does nothing, because the facts are already true. That is convergence, and it is what makes a definition safe to run on a schedule, in CI, or by someone who does not know the current state.
The property that falls out and matters most: because a declarative tool computes the difference before acting, it can *show you the difference*. An imperative script cannot preview itself. terraform plan exists because a desired-state model is required to produce it — this is the same control loop Kubernetes runs continuously against pods rather than on demand against cloud resources. See The Kubernetes Mental Model and Reading a Plan Before You Apply It.
Where declarative genuinely loses
A declarative model describes a *state*, so it is bad at things that are inherently an *event*. Rotate this credential now. Drain this node, wait for connections to finish, then terminate it. Take a snapshot, restore it into a scratch account, run a verification query, tear it down. None of those are facts about the world that should stay true; they are things you do once, in order, with checks between the steps.
Teams that have committed to declarative tooling often try to express these anyway, and the result is the ugliest code in the repository: null resources wrapping shell commands, triggers that fire on a changed timestamp, and provisioners that run at create time and silently never run again. The tool cannot help, because the operation has no steady state for it to converge on.
The honest split is by *nature of the thing*, not by preference. Long-lived structure — networks, clusters, identities, databases — is declarative. Point-in-time operations are a script, ideally one the pipeline can run and log.
- Declarative fits anything whose correct answer is "this should exist, configured this way, indefinitely".
- Imperative fits anything whose correct answer is "do this once, in this order, and check between the steps".
- A declarative tool with an escape hatch to shell is a warning sign, not a feature — it is the model telling you the work does not belong here.
- Configuration management (Ansible, Chef) sits in between: mostly declarative resource models, with an explicit ordered play, which is why it survives for in-place server operations.
- Ordering still exists in declarative tools; it is *derived* from references rather than written. When there is no reference, there is no ordering, and that is the source of the classic "it worked the second time" bug.
| Task | Nature | Right model | Why the other one hurts |
|---|---|---|---|
| A virtual network with three subnets | Long-lived structure | Declarative | A script cannot tell you it is about to renumber a subnet. |
| Rotate a database credential now | One-time, ordered | Imperative | There is no steady state to converge on; the rotation is the whole point. |
| Drain a node, wait, then terminate it | Ordered, with a wait condition | Imperative | Declarative tools have no natural way to say "and then wait until". |
| A cluster with an autoscaling group | Long-lived structure | Declarative | Hand-written existence checks silently accept a fleet larger than the target. |
| Restore a snapshot, verify, tear it down | A drill, run periodically | Imperative | Expressed declaratively it becomes a null resource wrapping shell — the worst of both. |
| Install and configure packages on a running host | In-place convergence with ordering | Configuration management | Pure IaC does not manage what is inside the machine; a pure script is not idempotent. |
The same task, both ways
The imperative version below is not badly written — it is what a careful engineer produces. Note how much of it is bookkeeping: existence checks, id capture, and an ordering that only works because the author knew it. The declarative version has none of that, and in exchange it has no way to express "and then wait for the health check to pass before continuing", which the script does trivially.
# every line is an action; every check is manual
SUBNET=$(cloud subnet list --filter name=app-a --query id -o text)
if [ -z "$SUBNET" ]; then
SUBNET=$(cloud subnet create --cidr 10.0.2.0/24 --query id -o text)
fi
EXISTING=$(cloud instance list --filter tag:role=api --query "length(items)")
if [ "$EXISTING" -lt 3 ]; then
for i in $(seq $EXISTING 2); do
cloud instance create --subnet "$SUBNET" --tag role=api
done
fi
# and if EXISTING is 5? the script has no opinion.# hcl: the tool derives the actions, including deletion
# resource "cloud_subnet" "app_a" { cidr = "10.0.2.0/24" }
# resource "cloud_instance" "api" {
# count = 3
# subnet_id = cloud_subnet.app_a.id
# tags = { role = "api" }
# }
#
# terraform plan on an account with 5:
# - cloud_instance.api[3] will be destroyed
# - cloud_instance.api[4] will be destroyedThe imperative version only handles the cases its author enumerated; it silently accepts a state above the target. The declarative version converges in both directions — which is also why it will happily destroy the two instances someone added by hand. See Drift: When the File and Reality Disagree.
Key points
- Declarative describes the target state and converges; imperative describes actions and executes them regardless of the current state.
- Idempotency is free in a declarative model and hand-written per step in an imperative one.
- Only a desired-state model can preview a change, which is where
plancomes from. - Convergence cuts both ways: it removes what it did not put there, so the same property that fixes drift also destroys unmanaged resources.
- A one-off operational task — rotate, drain, restore-and-verify — is legitimately a script, and forcing it into declarative syntax produces the worst code in the repository.
The loop, answered
Every field is required, which is why no lesson here can recommend something without saying what it costs and what simpler thing to consider first.
- • The declarative tool parses the configuration into a resource graph, with edges derived from references between resources.
- • It refreshes recorded state against the provider to learn what actually exists and with which attributes.
- • It computes a per-resource action — no-op, update in place, replace, create, destroy — by comparing desired attributes to observed ones.
- • It executes the actions in topological order, parallelising independent branches.
- • An imperative script skips the first three steps entirely: it holds no model, so every safety check is code the author wrote.
- • You own the ordering the tool cannot derive: when two resources have no reference between them but a real dependency, you add an explicit one.
- • You own the decision about what stays out of the declarative model — and the scripts that handle it, which also need review and logging.
- • You own the boundary: resources created by a script and later imported into the declarative model, or the tool will propose to create them again.
- • You own the semantics of
countand keys, because reindexing a counted resource is how a routine change becomes a replacement of everything after it.
- • A missing implicit dependency: two resources with no reference between them are created in parallel, and the apply fails roughly half the time.
- • A provisioner that runs only at create time; the configuration changes, the resource is not replaced, and the provisioning step silently never re-runs.
- • An imperative script run twice, creating duplicate resources that the declarative model does not know about and will not clean up.
- • Convergence destroying something legitimate: a resource added by hand for a good reason is removed on the next apply because it is not in the file.
- • Reindexing: an item removed from the middle of a counted list, and the plan proposes to destroy and recreate every resource after it.
- • Declarative scales with resource count: refresh time grows linearly with the number of managed resources, and plan time is what stops being usable first.
- • Imperative scales with the number of cases the author remembered, which is a worse curve — the script grows conditionals faster than it grows capability.
- • Parallelism in a declarative apply is bounded by the graph shape and by provider rate limits; a wide graph hits API throttling before it hits CPU.
- • A declarative model makes the security-relevant surface reviewable as a diff, which an imperative script only partly achieves — you review operations, not the resulting posture.
- • Shell escape hatches inside a declarative configuration run with the pipeline identity and are rarely reviewed as carefully as the resource blocks around them.
- • Convergence is a control: an out-of-band change to a firewall rule is reverted on the next apply, which is a real containment property for accidental exposure.
- • Convergence is a cost control: a declarative model can destroy what is no longer described, which is the only reliable way orphaned resources ever get cleaned up.
- • Imperative scripts leak resources by construction — the failure path of "created the instance, then errored" leaves the instance running and billed.
- • Plan-time provider API calls are free in money and expensive in wall-clock, which becomes a developer-productivity cost rather than a line item.
- • An empty plan on unchanged main is the health signal for a declarative estate; anything else is drift or provider skew.
- • Apply failure rate and the distribution of failure causes — dependency ordering failures cluster and point at a missing reference.
- • For scripts: exit codes and the resources created before the failure, because nothing else records what the half-run left behind.
- • The signal that lies: "the script ran successfully" when the script's existence checks were satisfied by resources that were wrong rather than absent.
- • A plain script, when the task is genuinely a one-time sequence. Rotating a key or draining a node does not have a steady state to converge on.
- • Configuration management with an ordered play, for in-place server configuration where the ordering and the wait conditions are the substance of the work.
- • The provider console, for reading. Nothing about declarative infrastructure requires you to stop looking at the actual resources — and you should.
- • No tool at all when the infrastructure is one managed platform service; a declarative model of a single resource is ceremony.
- • Buys idempotency and preview; costs the ability to express ordering, waiting and verification steps naturally.
- • Buys convergence back to the file; costs the destruction of anything legitimately added outside it.
- • Buys a reviewable diff of outcomes; costs a mental model — engineers must stop thinking in steps, and the transition is genuinely uncomfortable.
What people believe, and what is true
Declarative is always better.
It is better for long-lived structure and worse for point-in-time operations. A restore-and-verify drill expressed declaratively is a null resource wrapping a shell script, which has all the downsides of both models.
Declarative means order does not matter.
Order is derived from references. Where there is no reference there is no order, and the resulting race is intermittent — the classic "it worked when I re-ran it".
A declarative tool will never delete anything I did not ask it to.
Convergence means it removes what the file does not describe. That includes the emergency rule someone added at 3am.