Skip to main content
RunBook Academy

AnsibleVIII · Modules and the Module ModelThe module model

The module contract

Intermediate⏱ ~19 minansible

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

Not yet marked complete on this device.

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:

  1. Receives its arguments as structured data, not as a command line.
  2. Runs on the target, under the target’s Python, as the connecting user or as whoever become made it.
  3. Returns JSON on stdout and exits.
  4. 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:

Read-only / Safea module return value, unedited
$ 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.

KeyTypeMeaning
changedboolThe module claims it altered the target. Always present.
failedboolThe module claims the task failed. Absent means false.
msgstrA human-readable message, usually the reason for a failure.
skippedboolThe task was not run — a false when, or check mode with no support.
rcintExit status of a process the module ran, where one applies.
stdout / stderrstrCaptured output, where a process was run.
stdout_lines / stderr_lineslistThe same, pre-split on newlines.
resultslistOne result per item, when the task used a loop.
diffdictBefore and after content, shown with --diff.
warnings / deprecationslistSurfaced 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.

Read-only / Safethe module tells you its own return keys
$ 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: str

Illustrative 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:

Read-only / Safethe new setting, and its default
$ ansible-config dump | grep INJECT_INVOCATION
INJECT_INVOCATION(default) = False

So 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
Read-only / Safedefault: no invocation key
$ ansible-playbook keys.yml
TASK [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:

Read-only / Safeopt in for one run
$ ANSIBLE_INJECT_INVOCATION=1 ansible-playbook keys.yml
TASK [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 rc value and no failed value is deprecated. Modules and actions may use any logic desired to determine failure (including consulting rc), but failures must be explicitly communicated in the task result by setting failed true.

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

  1. 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?

  2. Q2. In ansible-core 2.21 a registered module result contains an invocation key recording the arguments the module was called with.

  3. Q3. Which of these behaviours break when a task reports changed inaccurately? Select all that apply.

  4. 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.