The Declarative Infrastructure Mental Model
Every tool covered in this section - Ansible, Pulumi, Terraform wrapped in Python, or a hand-rolled boto3 script - is judged by the same underlying model, whether or not its documentation says so explicitly.
That model is declarative infrastructure: you describe what should exist, a reconciliation process compares that description to what actually exists, and only the difference gets acted on.
This is a different way of programming than most engineers first learn, where code is a sequence of steps executed in order, and getting comfortable with the shift is what actually makes Ansible playbooks, Pulumi programs, and Terraform plans predictable instead of mysterious.
Summary
- Declarative infrastructure tools take a description of desired state and reconcile real infrastructure toward it, rather than executing a fixed sequence of imperative steps.
- Insight: Imperative scripts ("create this, then that") fail unpredictably on reruns and after partial failures; declarative reconciliation is designed to be safely rerun from any starting point.
- Key Concepts: desired state, idempotency, reconciliation loop, drift, state file, plan/apply.
- When to Use: Any infrastructure that will be created more than once, touched by more than one person, or need to converge after partial failure - which in practice means almost all real infrastructure past a solo prototype.
- Limitations/Trade-offs: Declarative tools trade fine-grained procedural control for predictability, and they introduce a new failure surface of their own - state file drift, locking, and plan/apply staleness.
- Related Topics: configuration management, GitOps, cloud provisioning, policy-as-code.
Foundations
An imperative script tells the computer exactly what to do, in order: create this bucket, then attach this policy, then create this queue.
Run it twice and the second run usually fails, because the bucket already exists and most APIs reject a duplicate create call outright.
A declarative tool instead asks you to describe the end state you want - "this bucket should exist, with versioning enabled" - and leaves the sequencing and duplicate-handling to the tool itself.
Idempotency is the property that makes this safe: an idempotent operation produces the same end result whether it runs once or ten times, so reruns after a crash, a network blip, or a CI retry are never destructive by default.
The mechanism underneath idempotency is a reconciliation loop: read the current real-world state, compare it to the declared desired state, compute the difference, and act only on that difference.
If the bucket already exists with versioning enabled, the reconciliation loop sees no difference and does nothing - that "no-op on rerun" behavior is the practical signature of a correctly idempotent system.
A simple analogy: a thermostat does not execute "turn on the heater for exactly 4 minutes" as a fixed script.
It continuously compares the room's actual temperature to the target temperature and acts only on the gap, which is why it works correctly regardless of what the room's starting temperature happened to be.
Ansible, Pulumi, and Terraform (with or without a Python wrapper) are all implementations of this same reconciliation idea, differing mainly in where they keep their record of current state and how they express desired state in code.
Mechanics & Interactions
The reconciliation loop needs two inputs to compute a diff: the desired state (your code) and a record of the current state.
Tools differ sharply in how they get the second input, and this difference explains most of their behavioral quirks.
Terraform and Pulumi maintain an explicit state file - a serialized record of every resource the tool believes it created and its last known attributes - and they diff your code against that file, not necessarily against live infrastructure, unless a refresh step runs first.
Ansible, by contrast, is mostly stateless: each task queries the live target directly (is this file present, is this package installed) and only then decides whether to act, which is why Ansible playbooks tend to be naturally idempotent per-task without a separate state file to manage.
This distinction is the single most common source of confusion for engineers moving between tools: "drift" in Terraform means the state file disagrees with real infrastructure (someone changed something in the console), while "drift" in Ansible means the live target disagrees with the playbook's declared state, checked fresh on every run.
Plan before apply exists because a diff against a stale or incomplete state record can be wrong, and the plan step is the human review point where a proposed destructive action (deleting a stateful resource, for example) gets caught before it executes.
# The reconciliation shape, independent of any specific tool's API
def reconcile(desired: dict, get_current: callable, apply_change: callable) -> dict:
current = get_current() # read real-world state
diff = {k: v for k, v in desired.items() if current.get(k) != v}
if diff:
apply_change(diff) # act only on the delta
return {"changed": bool(diff), "delta": diff}This is the shape every declarative tool implements internally, whether it is a 500-line Go binary (Terraform) or a Python resource provider (Pulumi) - the point is the comparison-then-delta-only pattern, not any specific implementation.
Locking is the other mechanical detail worth internalizing: because the state file (or the live target, for Ansible) is shared, concurrent applies from two engineers or two CI jobs racing against the same infrastructure will corrupt or conflict on that shared record unless the tool enforces a lock during apply.
Advanced Considerations & Applications
At scale, the reconciliation model has to handle a case the simple thermostat analogy does not: partial failure mid-apply, where some resources in a batch succeed and others fail.
A well-behaved declarative tool leaves the state file reflecting exactly what actually got created, so the next run's diff correctly picks up only the remaining work, rather than either re-attempting already-created resources or silently ignoring the failure.
Drift detection becomes a first-class operational concern once infrastructure is managed by more than one team or more than one tool - a manual console change, a separate script using boto3 directly, or an expired resource can all cause the declared and actual states to diverge silently, with no error raised until the next plan surfaces it as an unexpected change.
Policy-as-code extends the plan/apply gate: instead of a human eyeballing a text diff, an automated policy check inspects the planned delta and blocks the apply if it violates a rule (missing required tags, an overly permissive security group, a public S3 bucket), turning the review step into a testable, repeatable gate rather than a manual judgment call.
Multi-environment setups (dev, staging, prod) push the model further: each environment needs its own state record and often its own desired-state parameters, and the discipline that prevents an accidental prod apply is almost entirely about keeping state isolated per environment, not about the code being fundamentally different.
Python enters this picture in two distinct roles worth distinguishing: as a first-class IaC runtime (Pulumi, where Python is the desired-state language), or as an orchestration layer around another tool's plan/apply cycle (a script that runs terraform plan, checks policy, and conditionally runs terraform apply) - the reconciliation model underneath is identical either way.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| Imperative boto3 script | Full control, no extra tooling | Not idempotent by default, no drift detection, no plan step | Small one-off tasks, glue scripts |
| Ansible | Agentless, naturally idempotent per task, no state file to manage | Weaker built-in dependency graph across resources than Terraform/Pulumi | Configuration management, ad-hoc convergence |
| Terraform (HCL or via Python wrapper) | Mature state model, huge provider ecosystem, strong plan diffs | HCL is a separate language to learn (unless wrapped) | Multi-cloud, org-standard IaC with strict plan gates |
| Pulumi (native Python) | Real programming language - loops, functions, tests | Smaller provider ecosystem than Terraform in some areas | Teams that want IaC as genuine, testable Python code |
Common Misconceptions
- "Declarative just means using YAML or a config file instead of a script." The file format is incidental; what matters is whether a reconciliation loop diffs desired state against current state, versus a script simply executing steps in order regardless of what already exists.
- "Idempotent means the operation is read-only or harmless." Idempotent operations can absolutely create, modify, or delete resources - the guarantee is only that repeating the same operation does not produce a different or duplicated end result.
- "If Terraform's plan shows no changes, infrastructure definitely matches the code." The plan only compares code against the state file; if someone changed the real resource outside Terraform and no refresh has run, the state file itself can be stale even though the plan looks clean.
- "Ansible has no state, so it can't detect drift." Ansible checks live state on every task run instead of a cached file, which is a different mechanism for the same goal, not an absence of state-awareness.
- "Plan and apply are just a confirmation dialog." The plan step is a full computation of the actual delta against a specific state snapshot; treating it as a rubber-stamp skips the one place destructive changes are visible before they execute.
FAQs
What exactly does "idempotent" mean for infrastructure code?
- Running the same declaration twice produces the same end state both times.
- The second run should report "no changes" if nothing actually changed.
- It does not mean the operation is safe or reversible - only that repetition doesn't duplicate or corrupt the result.
Why do Terraform and Pulumi need a state file at all?
Cloud APIs generally cannot answer "which resources did my code create," only "what exists." The state file is the tool's own record linking your code's resource declarations to real resource IDs, which is what makes precise diffing and targeted updates possible.
Does Ansible have an equivalent of a Terraform state file?
Not by default - most Ansible modules query the live target directly on each run instead of consulting a cached record, which is why Ansible playbooks are naturally idempotent per task without separate state management.
What is infrastructure drift, mechanically?
Drift is any gap between the state a tool believes exists (its state file, for Terraform/Pulumi, or the live target, for Ansible) and what is actually deployed - commonly caused by manual console changes or a second tool modifying the same resources.
Why is "plan before apply" considered so important?
The plan step computes and displays the exact delta that apply will act on, which is the only point where a human or an automated policy check can catch an unexpected destructive change (like an accidental resource deletion) before it happens.
How is Pulumi different from Terraform if both are declarative?
Pulumi expresses desired state as native Python (or another general-purpose language) with real loops, conditionals, and functions; Terraform uses its own HCL language, though CDKTF lets you generate Terraform configuration from Python as a layer on top.
Can I write idempotent infrastructure code without any IaC tool?
Yes - a boto3 script that checks for existence before creating, and checks current configuration before updating, is idempotent by the same definition, just without a shared state file or a plan step.
What happens if an apply fails halfway through?
A correctly implemented state model records exactly which resources succeeded before the failure, so the next apply's diff only targets the remaining, not-yet-created resources - this is one of the main practical benefits of the reconciliation model over a linear script.
Why do teams add policy checks on top of plan/apply?
Because a human reviewing a large plan diff can miss a subtle but important issue (a public bucket, a missing tag); an automated policy check inspects the same diff programmatically and blocks the apply on specific, defined violations.
Is state locking really necessary for a small team?
It becomes necessary as soon as two people or two CI jobs might run apply against the same infrastructure concurrently - without a lock, both can read the same stale state and apply conflicting changes.
How does this model relate to configuration management tools specifically?
Configuration management (Ansible) applies the same declarative/idempotent model at the level of a single machine's configuration (packages, files, services) rather than at the level of provisioning entire cloud resources, but the reconciliation mental model is identical.
What's the biggest practical risk of the declarative model?
Treating the state file (or live-target check, for Ansible) as infallible - if it drifts out of sync with reality and no one notices, the next plan's diff can be dangerously wrong, proposing to "fix" something that a human intentionally changed out-of-band.
Related
- Infrastructure Automation Basics - hands-on idempotency and plan-before-apply examples
- Ansible - the stateless, per-task convergence model
- Pulumi (Python IaC) - declarative infrastructure as native Python
- Terraform with Python - CDKTF and wrapping the Terraform CLI
- Provisioning Cloud Resources - applying the model to real cloud resources
- Testing Infrastructure Code - validating plans before they apply
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.