Skip to content

5. Modules and idempotence

Chapter 1 said idempotence is a property of the module, not of Ansible. This chapter is what that means in practice, because it is the difference between a playbook you can run every hour and one nobody dares run twice.

A module is a program that:

  1. Reads its arguments
  2. Looks at the current state of the system
  3. Decides whether anything needs doing
  4. Does it, if so
  5. Prints one line of JSON saying what happened

Step 2 is the whole thing. ansible.builtin.package asks the package manager whether nginx is installed before deciding to install it. That check is what makes it idempotent, and it is code somebody wrote inside the module.

{"changed": false, "msg": "", "name": "nginx", "state": "present"}

The changed flag drives more than the colour of the output:

changed → notifies handlers (chapter 4)
changed → counts in the play recap
changed → fails an idempotence test (chapter 13)
changed → shows a diff under --diff
changed → is what a human scans for in a log

A playbook whose second run reports twenty changes is a playbook nobody can read. The signal is gone: you can no longer tell “something actually happened” from “this always says that”. Recovering that signal is worth real effort, and it is mostly about the next section.

# This reports "changed" every single time, forever.
- ansible.builtin.command: /usr/bin/mkdir /opt/app

The module ran a command. It has no idea whether the command did anything. So it reports changed, because something might have happened.

There are three fixes, in strict order of preference.

- ansible.builtin.file:
path: /opt/app
state: directory
mode: '0755'

This is nearly always available and nearly always the answer. Before writing command, search for the module — there are thousands, and the ones you need most often are in the table at the end of this chapter.

If there genuinely is no module, command and shell take two arguments that give you idempotence for free:

- name: Extract the release
ansible.builtin.command:
cmd: tar xzf /tmp/app-2.4.0.tar.gz -C /opt
creates: /opt/app-2.4.0/bin/app # skip if this exists

creates skips the task entirely if the path exists. removes skips it if the path does not exist. These turn an unconditional command into a conditional one, and the task reports skipped on subsequent runs — which is honest.

When the command must run every time, tell Ansible how to interpret the result:

- name: Check the cluster status
ansible.builtin.command: /usr/local/bin/cluster-status
register: status
changed_when: false # read-only: never a change
failed_when: status.rc not in [0, 2] # 2 means "degraded"
- name: Reload the application
ansible.builtin.command: /opt/app/bin/reload
register: reload
changed_when: "'reloaded' in reload.stdout"
failed_when: reload.rc != 0 or 'ERROR' in reload.stderr

changed_when: false on every read-only command is the single highest-value habit in this chapter. Anything that queries, checks, or reports is not a change, and saying so removes most of the noise from a second run.

Note also that failed_when replaces the default check entirely. Once you write it, a non-zero return code is no longer automatically a failure — your expression is the only thing that decides. Include the return code in it unless you mean to ignore it.

- ansible.builtin.command: /opt/app/bin/reload
ignore_errors: true # ← almost always wrong

ignore_errors means “carry on regardless”, and it usually appears because a task failed and someone wanted the playbook to finish. It hides the failure without addressing it. failed_when says what failure means; ignore_errors says failure does not matter. The second is rarely true.

- ansible.builtin.command: ls /opt # no shell
- ansible.builtin.shell: ls /opt | wc -l # a shell

command does not use a shell. No pipes, no redirection, no globbing, no &&, no environment variable expansion. The arguments go straight to execve. This makes it safer — nothing is interpreted — and it is why command should be your default.

shell runs through /bin/sh, so all of that works, and so does shell injection if any part of the command comes from a variable. If you must use shell with untrusted input, quote it:

- ansible.builtin.shell: "grep {{ pattern | quote }} /var/log/app.log"

The | quote filter is not optional there. A variable containing ; rm -rf / is a real thing that has happened to real people.

--check runs the playbook without changing anything, and reports what would change.

Terminal window
ansible-playbook site.yml --check --diff
TASK [Deploy site configuration] ******************
--- before: /etc/nginx/conf.d/site.conf
+++ after: /etc/nginx/conf.d/site.conf
@@ -3,7 +3,7 @@
server {
- worker_connections 1024;
+ worker_connections 4096;
changed: [web1.example.com]

--check --diff together is the most useful pair of flags in Ansible. It answers “what will this do to production” with a line-by-line answer.

Three limitations to know:

Not every module supports it. Modules declare whether they do. command and shell skip entirely in check mode rather than guessing, which means a check run of a playbook full of command tasks tells you very little.

Read-only commands should opt in:

- ansible.builtin.command: /usr/local/bin/cluster-status
register: status
changed_when: false
check_mode: false # actually run this, even in --check

Without that, status is undefined in check mode and every later task that references it fails. This is the commonest reason --check does not work on a playbook that works fine normally.

Check mode cascades. If task 3 would have created a file, task 8 that expects the file to exist will fail — because in check mode task 3 did not create it. A playbook that bootstraps something from nothing often cannot be checked end to end, and that is inherent rather than a bug.

The ones you will use constantly, all from ansible.builtin unless noted:

FILES
copy push a file (or inline content)
template push a file through Jinja2 — chapter 7
file create/remove paths, set owner and mode
lineinfile ensure ONE line matches a regex
blockinfile ensure a marked BLOCK is present
replace regex substitution across a file
fetch pull a file back from the target
unarchive extract a tarball or zip
stat inspect a path without changing it
find search for files by criteria
assemble build one file from a directory of parts
PACKAGES
package generic — picks apt/yum/dnf/etc
apt / dnf / yum / zypper / apk / pacman
pip Python packages
package_facts what is installed, as data
SERVICES
service generic
systemd_service the real one on modern Linux
ansible.posix.mount
USERS
user / group
ansible.posix.authorized_key
EXECUTION
command no shell — the safer default
shell a shell, when you need one
raw no Python needed — chapter 3
script copy a local script and run it
CONTROL
debug print a message or a variable
assert fail unless conditions hold
fail fail with a message
set_fact define a variable at runtime
include_tasks / import_tasks / include_role
meta flush_handlers, end_play, clear_facts
pause / wait_for / wait_for_connection
uri make an HTTP request
get_url download a file

Three of these deserve a warning.

lineinfile is overused. It is right for a single line in a file you do not otherwise own — one setting in /etc/ssh/sshd_config. It is wrong as a way to build a config file line by line, which produces a playbook nobody can read and a file whose contents depend on the order rules ran. If you own the file, use template and own all of it.

replace has no idempotence guarantee you can see. A regex that matches its own output loops forever across runs, appending or doubling. Test the second run.

systemd_service over service on modern Linux. The generic service module works, and systemd_service gives you daemon_reload, masked, and proper handling of units that the generic one guesses at. All three matter: a unit file change does nothing until daemon-reload, and masking is the only thing that stops a unit being started by something else.

Every module returns a dictionary, and the common keys are worth knowing because register puts all of them in reach:

changed did something happen
failed did it fail
msg human-readable message
rc return code (command/shell)
stdout full output as one string
stdout_lines the same, split into a list
stderr / stderr_lines
results the per-item list when using `loop`
skipped was it skipped

stdout_lines is almost always what you want over parsing stdout yourself. And when a task loops, the result is results — a list of individual results — which is why register plus loop needs result.results rather than result.stdout. Chapter 8 covers that.

Rarely necessary, and worth knowing the shape because it demystifies the whole system:

#!/usr/bin/python
from ansible.module_utils.basic import AnsibleModule
def main():
module = AnsibleModule(
argument_spec=dict(
name=dict(type='str', required=True),
state=dict(type='str', default='present',
choices=['present', 'absent']),
),
supports_check_mode=True,
)
name = module.params['name']
current = look_at_the_system(name) # step 2
needed = (module.params['state'] == 'present')
if current == needed:
module.exit_json(changed=False, name=name)
if not module.check_mode:
do_the_thing(name, needed)
module.exit_json(changed=True, name=name)
if __name__ == '__main__':
main()

Put that in a library/ directory beside your playbook and it is available immediately. The shape is the lesson: check first, act only if needed, declare check-mode support, and print JSON. That is all a module is.

Before writing one, though: a task that shells out and parses output with changed_when is usually enough, and a module is worth it when you would otherwise repeat that in twenty places.

  • A module inspects the system before acting. That check is what idempotence is; Ansible does not provide it.
  • changed drives handlers, the recap, diffs, and idempotence tests. A playbook whose second run reports twenty changes has lost its signal.
  • command and shell always report changed. Fix it, in order: use the real module, then creates/removes, then changed_when.
  • changed_when: false on every read-only command is the highest-value habit here.
  • failed_when replaces the default return-code check entirely — include rc in your expression unless you mean to ignore it. ignore_errors is almost always the wrong tool.
  • command uses no shell and is the safer default. With shell, | quote any variable in the command string.
  • --check --diff is the most useful flag pair. command/shell skip in check mode, so add check_mode: false to read-only commands or later tasks will fail on undefined variables.
  • lineinfile is for files you do not own. If you own the file, use template.
  • stdout_lines over parsing stdout, and a looped task returns results, not stdout.
  • A custom module is just check, act, print JSON, and lives in library/ beside the playbook.

Next: variables — where they come from, the precedence order that decides which one wins, and the scoping rules that produce most of the confusion in this tool.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source