Modules and Idempotency
Idempotency (safe to re-run) is the property that sets Ansible apart from a bash script. Article 4 already showed it: running the playbook a second time gives changed=0. This article is the deep dive — how a module achieves that, which is also the basis for writing your own module in Article 11.
Module: a recap
Recall Article 1: a module is a piece of code (usually Python) that Ansible ships to the host and runs with the host's Python, taking parameters as JSON and returning results as JSON. Each task is one call to a module. Ansible ships thousands of modules, grouped by the kind of job: packages (dnf, apt), files (copy, template, file), services (service, systemd), users (user), and so on.
How idempotency works
The key: a module doesn't run blind — it checks the current state before acting. You declare the desired state (Article 4: state: present), and the module handles the comparison and changes only if needed:
Task: "file X must have content Y" (desired state)
│
Module runs on the host:
├─ read the CURRENT state of X
├─ compare to the DESIRED state (Y)
├─ same? ──────► do NOTHING → returns "changed": false (ok)
└─ different? ─► fix it to match Y → returns "changed": true (changed)
This is the reason (Article 4):
- Run 1: nginx not installed, service not running, file absent → the module sees "differs from desired" → acts →
changed=3. - Run 2: everything is already correct → the module sees "already matches" → does nothing →
changed=0.
The changed field in the returned JSON is exactly how Ansible knows whether a task changed anything, and prints ok (yellow if changed) for you.
Observing idempotency: check mode and diff
You can see what a module "thinks" without actually applying it, using check mode (--check) plus --diff. Try it: change the file content in the playbook, then run a check:
ansible-playbook site.yml --check --diff
TASK [Deploy the index page] *********************
--- before: /usr/share/nginx/html/index.html
+++ after: /usr/share/nginx/html/index.html
@@ -1 +1 @@
-<h1>Trien khai bang Ansible</h1>
+<h1>Trien khai bang Ansible v2</h1>
changed: [lab]
PLAY RECAP ****************************************
lab : ok=4 changed=1 ...
The copy module: read the current content on the host, compared it to the desired content (v2), saw a difference → reports it will be changed along with the exact diff. But because it's check mode, it didn't actually apply — verify it: curl right afterward still returns the old content (no "v2" yet). This is the power of check mode: see ahead of time what a playbook will change in production before running it for real (Article 13).
Why command and shell are NOT idempotent
This is an extremely important point. Dedicated modules (dnf, copy, service) understand state so they're idempotent. But command/shell just run an arbitrary command — Ansible doesn't know what that command does, so it can't compare state. The upshot: they always report changed on every run (as we saw in Article 2).
ansible -m dnf -a "name=nginx state=present" → nginx already there: ok (idempotent)
ansible -m command -a "dnf install -y nginx" → ALWAYS changed (Ansible is blind)
The golden rule: use a dedicated module instead of command/shell whenever possible. When you're forced to use command/shell, make them idempotent manually with:
creates:/removes:— "only run if this file does NOT/DOES already exist". Examplecommand: ./install.sh creates=/opt/app→ skip if/opt/appalready exists.changed_when:/failed_when:— define yourself when something counts as changed/failed based on the result (Article 7).
The state parameter: the language of state
Most modules use the state parameter to declare the desired state:
state: present the thing must EXIST (install package, create file/user)
state: absent the thing must NOT exist (remove/delete)
state: started the service must be RUNNING
state: stopped the service must be STOPPED
state: latest the package must be at the LATEST version
To remove nginx, just change state: present → state: absent — the module handles the rest, idempotent in both directions.
A tour of common modules
A few modules you'll meet constantly (all in ansible.builtin, idempotent):
Packages: dnf / apt / package install/remove software (package = cross-distro)
Files: copy copy a file/content to the host
template like copy but renders Jinja2 first (Article 6)
file create/delete files/dirs, set permissions (state, mode)
lineinfile ensure ONE line is present/absent in a file
blockinfile ensure a BLOCK of text is in a file
Services: service / systemd start/stop/enable a service
Users: user / group manage users/groups (recall Linux series Article 12)
Get code: git clone/checkout a repo
Commands: command / shell run a command (NOT idempotent — use sparingly)
To look up a module's docs: ansible-doc <module_name> (e.g. ansible-doc copy) — prints the description, parameters, and examples right in the terminal. This is the quick way to look things up instead of opening docs.ansible.com.
Wrap-up
A module achieves idempotency by reading the current state on the host, comparing it to the desired state you declared (state:), changing only the difference, then returning changed: true/false. That's why re-running a playbook is always safe — it's the reason changed=3 (first run) becomes changed=0 (later runs). --check --diff shows the change ahead of time without applying it. command/shell are NOT idempotent (Ansible doesn't understand arbitrary commands) — prefer dedicated modules, or add creates/removes/changed_when. Use ansible-doc <module> to look things up.
So far we've hard-coded values in the playbook. Article 6 makes them flexible: variables, facts (host information), and Jinja2 templates to generate config files dynamically.