AnsibleVIII · Modules and the Module ModelThe module model
The module contract
What you'll learn
- State the contract every Ansible module honours
- Name the common return keys and say which are claims made by the module rather than observations made by Ansible
- Explain why an inaccurate changed value corrupts handlers, reporting and audit
- Locate the invocation record in ansible-core 2.21, where it is no longer returned by default
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Everything Ansible does to a managed node, it does through a module.
Understanding what a module is — precisely, not approximately — settles
a surprising number of arguments about idempotency, check mode, change
reporting and why shell is not just an untidy alternative to a real
module.
The contract
A module is a program that:
- Receives its arguments as structured data, not as a command line.
- Runs on the target, under the target’s Python, as the connecting
user or as whoever
becomemade it. - Returns JSON on stdout and exits.
- Declares in that JSON whether it changed anything and whether it failed.
That is the whole contract. There is no fifth clause where Ansible checks the module’s work.
Point 4 is the one with consequences. Ansible does not inspect the target
to find out whether something changed. It reads the changed key the
module returned and believes it. The entire reporting layer — the
PLAY RECAP, handler firing, --check predictions, your change
dashboards, your compliance evidence — is downstream of a claim made by a
program that ran for forty milliseconds on a machine you were not
watching.
The return value
Run any module and you see its JSON directly:
$ ansible localhost -m ansible.builtin.stat -a "path=/etc/hostname"localhost | SUCCESS => {
"changed": false,
"stat": {
"atime": 1786428080.6388974,
"checksum": "5e088b17baa85e6be9f54cf82b0e96e4f8c9f9a1",
"exists": true,
"gid": 0,
"gr_name": "root",
"isreg": true,
"mode": "0644",
"mtime": 1778020738.4909694,
"path": "/etc/hostname",
"pw_name": "root",
"size": 9,
"uid": 0
}
}Illustrative output
The common keys
Some keys are conventional across every module. Learn these and you can read any module’s output.
| Key | Type | Meaning |
|---|---|---|
changed | bool | The module claims it altered the target. Always present. |
failed | bool | The module claims the task failed. Absent means false. |
msg | str | A human-readable message, usually the reason for a failure. |
skipped | bool | The task was not run — a false when, or check mode with no support. |
rc | int | Exit status of a process the module ran, where one applies. |
stdout / stderr | str | Captured output, where a process was run. |
stdout_lines / stderr_lines | list | The same, pre-split on newlines. |
results | list | One result per item, when the task used a loop. |
diff | dict | Before and after content, shown with --diff. |
warnings / deprecations | list | Surfaced by the callback, not usually consumed. |
Everything else is module-specific and documented in that module’s
RETURN VALUES section — stat for ansible.builtin.stat, ping for
ansible.builtin.ping, ansible_facts for ansible.builtin.setup.
$ ansible-doc ansible.builtin.command | sed -n '/RETURN VALUES/,$p'RETURN VALUES:
cmd The command executed by the task.
returned: always
sample: [echo, hello]
type: list
delta The command execution delta time.
returned: always
sample: '0:00:00.001529'
type: str
rc The command return code (0 means success).
returned: always
sample: 0
type: int
stdout The command standard output.
returned: always
type: strIllustrative output
The output above is trimmed: command also documents end, msg,
start, stderr, stdout_lines and stderr_lines. Read the whole
section rather than this excerpt when you are writing against it.
stdout_lines is not a convenience, it is the correct one
Given stdout and stdout_lines, use stdout_lines. Splitting a string
in Jinja works until a line contains something you did not anticipate,
and the module already did the split correctly on the target where the
line endings came from.
Why changed is load-bearing
changed looks like reporting. It is control flow.
Handlers depend on it. notify fires when the notifying task
reported changed: true. A task that always claims a change restarts
your services on every run, forever.
Check mode depends on it. In --check, a module reports the
changed it would have produced. If it cannot tell, --check cannot
tell you either.
Conditionals depend on it. when: previous_task is changed is a
common and correct pattern that becomes meaningless against a task that
always claims a change.
Your evidence depends on it. “This run changed 3 hosts out of 300” is the sentence that makes a scheduled run reviewable. Against tasks that always claim a change it reads “this run changed 300 hosts out of 300”, every night, and nobody looks at it after week two.
Two version-specific changes worth knowing
ansible-core 2.21 tightens the contract in two places. Both are the
kind of detail that turns into a confusing afternoon if you learned
Ansible on an older release.
invocation is no longer returned by default
Historically every module result carried an invocation key recording
the arguments it was called with. In 2.21 that is opt-in, controlled by a
new setting:
$ ansible-config dump | grep INJECT_INVOCATIONINJECT_INVOCATION(default) = FalseSo a registered result contains only what the module returned. This play asks the result what keys it has:
# keys.yml
- name: What keys does a module actually return
hosts: localhost
gather_facts: false
tasks:
- name: Stat a file
ansible.builtin.stat:
path: /etc/hostname
register: h
- name: List the top-level keys of the result
ansible.builtin.debug:
var: h.keys() | list
$ ansible-playbook keys.ymlTASK [List the top-level keys of the result] ***********************************
ok: [localhost] => {
"h.keys() | list": [
"changed",
"failed",
"stat"
]
}Turn it on when you need to see exactly what a module was called with — which is the single most useful thing when a templated argument did not resolve to what you assumed:
$ ANSIBLE_INJECT_INVOCATION=1 ansible-playbook keys.ymlTASK [List the top-level keys of the result] ***********************************
ok: [localhost] => {
"h.keys() | list": [
"changed",
"failed",
"stat",
"invocation"
]
}Note that most callbacks still hide invocation from the screen below
-vvv even when it is present. Registering the result and printing it is
the reliable way to see it.
Failure inference from rc is deprecated
Historically, a module or action that returned a non-zero rc and no
explicit failed value was inferred to have failed. The 2.21 porting
guide deprecates that inference:
Failure inference for modules and actions that return a non-zero
rcvalue and nofailedvalue is deprecated. Modules and actions may use any logic desired to determine failure (including consultingrc), but failures must be explicitly communicated in the task result by settingfailedtrue.
Runtime deprecation warnings arrive in 2.22, and when the inference is
removed, rc receives no special treatment during result processing.
The direction of travel is the point of this lesson restated by upstream: the module must say whether it failed. Ansible is getting out of the business of guessing from a side channel.
Knowledge check
Knowledge check · 4 questions
Q1. A task using ansible.builtin.command reports CHANGED on every run even though the command only reads a value. What is the correct explanation?
Q2. In ansible-core 2.21 a registered module result contains an invocation key recording the arguments the module was called with.
Q3. Which of these behaviours break when a task reports changed inaccurately? Select all that apply.
Q4. What does the 2.21 deprecation of "failure inference from non-zero rc" mean for module authors and for people reading module results?
Passing score: 75%. Answers are checked in this browser.