The Module Model
A module is the unit of work in Ansible — a small program that takes desired state as arguments, inspects the machine's current state, and makes only the changes needed to match. It is where idempotency physically lives: the apt module checks whether nginx is already installed before doing anything, and reports ok when there was nothing to do or changed when it acted, honestly. That honest reporting is what lets you run the same play every hour and have it settle after the first run.
There is no state file telling the module what it did last time. The managed node is the record, re-read live on every run. Everything in this topic follows from that one fact, and it is the spine the whole course turns on.
Desired State In, Result Out
A module is called with arguments describing the end state — name=nginx state=present — and returns a JSON result describing what it found and what it did. The module owns the logic of getting from current to desired, so you declare the destination and it computes the route. You never write the apt-get install step; you state that nginx should be present and let the module decide whether anything is needed.
This is the declarative core of Ansible in miniature. Two engineers can write the same task and mean exactly the same thing, because the task names an outcome, not a procedure. The procedure is the module's business, and it varies with what the machine already looks like.
The ok / changed / failed Contract
Every module reports one of three outcomes per host: ok means the machine was already in the desired state and nothing was done, changed means it altered the machine, and failed means it could not reach the desired state. The play's recap counts these, and the signal you want is plain — a correct play is all ok on its second run, because the first run already converged it.
Read the recap as a convergence signal, not noise. A green ok does not mean "Ansible did the work" — it means the work was already done. The line that acted is changed, and a persistent changed on every run is a footgun pointing at a module that is not actually idempotent.
Why changed Must Be Honest
The changed flag is not cosmetic. It is what fires handlers — reload nginx only when the config actually changed — what --check reports, and what tells you a "no-op" run was truly a no-op. The whole notification system that you will build in playbooks hangs off this one boolean being true exactly when something changed.
A module that reports changed when nothing changed poisons all of that. It reloads services needlessly, makes every run look unsettled, and trains you to ignore the very signal you should be reading. This is why the next topic draws such a hard line around command and shell, which cannot report changed honestly on their own.
Idempotency Is Per-Module, Against Live State
Each module reads the machine's real state every run and compares it to the declared arguments, which makes idempotency stronger than a stored snapshot that can quietly lie about reality. A file someone edited by hand is detected and re-converged, because the truth is on the disk Ansible just read, not in a state file written hours ago that the world has since moved past.
That live re-read is the safety net the no-state-file model gives you. Drift does not accumulate silently and surface during an incident — the next run finds it and corrects it, because the module always asks the machine directly rather than trusting a record of what was supposed to be true.
The Execution Mechanism
Concretely, Ansible generates the module code, copies it to the managed node over SSH into a temporary directory, runs it under the node's Python 3, captures the JSON it prints on stdout, and removes it. Nothing of Ansible stays behind on the host. That is the agentless model made physical: no daemon, no installed agent, just a small program copied in, run once, and cleaned up.
ok / changed / failedBecause each run copies the module over fresh and deletes it after, there is no module state to persist between runs and none to corrupt. Each run re-reads the node from scratch. There is nothing to rely on across runs and, equally, nothing to break — the machine itself carries all the memory the system has.
Reading Return Values
Modules return structured data, and you capture it by registering a task's result. Alongside changed and failed, a module returns its own keys — stdout, dest, diff, and others specific to what it does. Later tasks branch on these values, so knowing a module's return shape is as important as knowing its arguments, and the next topics show where to read both.
Register the result and branch on what the module actually returned rather than hard-coding an assumption. If a later task needs to know whether a file changed, read the changed the copy module reported; do not re-derive it with a separate command that re-checks the disk and adds a second non-idempotent step.
Terraform plan — computes the diff between the state file and the config before applying, so its prediction is only as current as the recorded snapshot. If the world drifted from state, the plan can be wrong until you refresh.
Ansible module — computes the diff between live machine state and the declared arguments at run time, per host, with no stored state in between. Ansible's "what would change" is the module checking reality on the spot, not a planner reading a recorded snapshot — which is why it catches the hand-edited file a stale plan would miss.
- Assuming a green
okmeans "Ansible did the work" —okmeans the machine was already in the desired state and nothing was done;changedis the one that acted, and confusing the two hides whether a run actually converged anything. - Treating the
changedcount as noise and ignoring a play that reportschangedon every run — a perpetually-changing task usually means a non-idempotentcommandorshellthat should be guarded, and it fires handlers every single run. - Writing a custom step that always reports
changedregardless of whether it changed anything, then watching handlers likereload nginxfire on every run and churn the web tier needlessly. - Expecting module state to persist between runs the way a state file does — modules are copied over, run once, and deleted, so each run re-reads the node from scratch; there is no memory to corrupt and none to rely on.
- Not registering a module's result when a later task needs to branch on it, then hard-coding an assumption instead of reading the actual
changed,failed, orstdoutthe module returned.
- Read a play recap as a convergence signal: a correct, idempotent play is all
okon its second run, and any persistentchangedis a footgun to investigate. - Rely on idempotent modules so
changedstays honest, because handler firing,--checkaccuracy, and "is this run a no-op?" all hang off that flag. - Register a task's result and branch on its return values —
changed,rc,stdout— rather than re-deriving state with a separatecommand. - Trust the live machine as the source of truth: a hand-edited file is detected and re-converged on the next run, which is exactly the safety net the no-state-file model gives you.
- Read a module's return section before writing the task that depends on it, so you branch on keys the module actually emits instead of ones you assumed.
Knowledge Check
A task reports ok rather than changed. What does that tell you?
- The machine was already in the desired state, so the module did nothing this run
- The module ran successfully and went ahead and made the exact change you asked for
- The module was simply skipped over this time because its arguments turned out invalid
- The task itself succeeded fine, but its result just was not registered to a variable
Why does honest changed reporting matter beyond the recap count?
- Handlers fire on
changedand check mode reports it, so a module lying about change reloads services needlessly and makes runs look unsettled - A wrong
changedcount is precisely what causes Ansible to go and roll the whole run back fully automatically for you - The
changedflag gets written straight out to the central state file on disk and then quietly corrupts it whenever it happens to be inaccurate - An inaccurate
changedflag will just outright prevent the very next task in the entire play from running at all
Where does Ansible's idempotency live, and why is it stronger than a stored snapshot?
- In each module, which re-reads the live machine every run and compares to the declared state, so a hand-edited file is caught and re-converged
- In a single central state file that the control node always consults very carefully indeed before each and every single run that it ever performs
- In the SSH transport layer itself, which quietly deduplicates away any single one of the commands that it has already once sent over before
- In one global engine setting that simply skips over any task that has already run before once
How does a module physically run on the managed node?
- Ansible copies the generated module code over SSH into a temp dir, runs it under the node's Python, reads its JSON on stdout, then deletes it
- A long-running persistent Ansible agent daemon already on the node is what executes the module and then caches its result for later reuse
- The control node simply runs the whole module entirely locally itself and then just applies the resulting changes back out remotely to the host
- The module gets installed permanently onto the node on its very first use and is then reused from there thereafter
You got correct