An Ansible task like - apt: name=nginx state=present doesn't say "install nginx" — it says "nginx should be present." That distinction is the entire reason idempotency is possible.
Before making any change, a well-written module queries the actual current state on the host — e.g. the apt module checks the local package database for whether nginx is already installed at the requested version.
InspectThe module diffs what it found against what the task asked for. This comparison logic is written into each module individually — there's no generic engine doing this centrally; it's why some third-party modules are less reliably idempotent than core ones.
CompareIf current state already matches desired state, the module does nothing further and reports changed: false. If it doesn't match, the module performs the minimum action needed to converge, then reports changed: true. This single branch is the whole idempotency mechanism.
Checkpointnginx isn't installed. The apt module runs the actual installation, then returns {"changed": true, ...}. Ansible prints the task line in yellow in the terminal.
Run the exact same playbook again. This time apt finds nginx already at the requested state, does nothing, and returns {"changed": false, ...}. Ansible prints the task line in green as "ok."
If the task has notify: restart nginx, that handler only gets queued when changed is true. On the second run, since nothing changed, the handler is never queued — nginx is not needlessly restarted.
Notify GateThe shell and command modules have no way to inspect "current state" for an arbitrary command — they always report changed: true on every run, whether or not the command actually changed anything, unless you explicitly override it.
ExceptionFor shell/command tasks, you can tell Ansible how to interpret the result yourself: changed_when: "'installed' in result.stdout". This restores idempotent-looking output even though the module itself can't detect it natively.
Running with --check triggers the exact same "compare current to desired" logic, but the module stops right after the decision in step 4 and reports what it would have done, without doing it — that's the entire mechanism behind a dry run.
Dry Run| Term | Meaning |
|---|---|
| Idempotent | Running the same operation repeatedly produces the same end state, with no side effects after the first successful run |
| changed | JSON field a module returns indicating whether it altered the host's state |
| changed_when | Task-level override that lets you define your own logic for the changed flag |
| failed_when | Task-level override for what counts as failure, independent of changed |
| --check | Dry-run flag — reports what would change without applying it |
| --diff | Shows the actual before/after content difference for file-based modules |
| shell / command | Modules that run arbitrary commands and can't natively detect idempotency |
| Handler | A task triggered only when notified by a changed:true task, run once at play end |
| register | Captures a task's full return JSON into a variable for later use |
| Desired State | What the task declares should be true, as opposed to a literal sequence of commands |