Skip to main content
RunBook Academy

AnsibleXIV · Facts and Registered VariablesRegistered variables

register and the shape of a result

Intermediate⏱ ~25 minansible-playbookansible-doc

What you'll learn

  • Name the common keys on a registered result and say which are always present
  • Print the real structure of a result instead of guessing at key names
  • Explain why a looped task produces results and not the module keys
  • Choose stdout_lines over splitting stdout, and know when neither exists

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.

register saves a task’s result into a variable. That sentence is the whole feature and it is also where most of the trouble starts, because “the task’s result” is not one shape. It is whatever that particular module chose to return, wrapped differently depending on whether the task looped, and different again if the task was skipped.

Part VIII established the module contract: a module returns JSON and that parsed document is the result. This lesson is about reading the document rather than assuming what is in it.

The single most valuable habit here is trivially simple and almost nobody does it: print the result before you write anything that reads it.

The anatomy of a single result

Read-only / Safethe top-level keys of a command result
$ ansible-playbook reg.yml
TASK [The top-level keys of a single result] ***********************************
ok: [localhost] => {
  "single.keys() | list": [
      "changed",
      "failed",
      "msg",
      "rc",
      "changed_when_result",
      "stdout",
      "stderr",
      "cmd",
      "start",
      "end",
      "delta",
      "stdout_lines",
      "stderr_lines"
  ]
}

And the whole thing:

Read-only / Safethe full result
$ ansible-playbook reg.yml
TASK [The whole single result] *************************************************
ok: [localhost] => {
  "single": {
      "changed": false,
      "changed_when_result": false,
      "cmd": [
          "/bin/echo",
          "-e",
          "alpha\nbeta"
      ],
      "delta": "0:00:00.002939",
      "end": "2026-08-11 21:32:58.602651",
      "failed": false,
      "msg": "",
      "rc": 0,
      "start": "2026-08-11 21:32:58.599712",
      "stderr": "",
      "stderr_lines": [],
      "stdout": "alpha
beta",
      "stdout_lines": [
          "alpha",
          "beta"
      ]
  }
}

Two things in that output are worth noticing immediately.

changed_when_result appears because the task carried a changed_when. It is not a standard key — it is there because of something the task did. Half of what you find on a result is like this: present because of how the task was written, not because the module always returns it.

There is no invocation key. On older versions every result carried one recording the arguments the module was called with. In 2.21 that is opt-in behind INJECT_INVOCATION, which defaults to false. If you learned Ansible before 2.21 and are looking for invocation, that is where it went.

Which keys you can rely on

KeyAlways present?Notes
changedYesThe module’s claim, not an observation
failedEffectivelyAbsent means false
msgUsuallyThe human-readable reason, especially on failure
skippedOnly when skippedIts absence is how you know the task ran
rcOnly where a process rancommand, shell, script, raw
stdout / stderrOnly where a process ran
stdout_lines / stderr_linesAlongside stdout / stderrPre-split, on the target
resultsOnly when the task loopedAnd then the module keys are not at the top level

Everything else is module-specific: stat returns stat, uri returns status and content, slurp returns content base64 encoded. That is what ansible-doc is for:

Read-only / Safeask the module what it returns
$ ansible-doc ansible.builtin.stat | sed -n '/RETURN VALUES/,$p'
RETURN VALUES:

 stat    dictionary containing all the stat data, some platforms
      might add additional fields
      returned: success
      type: dict
      contains:
         exists  If the destination path actually exists or not
              returned: success
              type: bool
         checksum  hash of the file
              returned: success, path exists, user can read stats,
              path supports hashing and supports_checksum is true
              type: str

Illustrative output

Read the returned: line, not just the key name. checksum is returned under four conditions, all of which have to hold. Writing result.stat.checksum on a path that might be a directory produces an undefined error, and the documentation said so.

The habit: print it first

Read-only / Safefour lines that save an afternoon
- name: The task whose result you need
ansible.builtin.stat:
  path: /etc/hostname
register: result

- name: What is actually in there
ansible.builtin.debug:
  var: result

For a large result, print just the keys:

- name: Top-level keys only
  ansible.builtin.debug:
    var: result.keys() | list

This costs one round trip and it is the difference between writing the conditional once and writing it four times against a -vvv transcript.

A loop changes the shape completely

This is the part that trips people constantly, and it deserves real output rather than a description.

Same module, same play, one task without a loop and one with. The looped task registers a result with a different top-level structure:

Read-only / Safethe top-level keys of a LOOPED result
$ ansible-playbook loopreg.yml
TASK [Stat three paths] ********************************************************
ok: [localhost] => (item=/etc/hostname)
ok: [localhost] => (item=/etc/os-release)
ok: [localhost] => (item=/etc/definitely-not-here)

TASK [Top-level keys of the LOOPED result] *************************************
ok: [localhost] => {
  "stats.keys() | list": [
      "changed",
      "failed",
      "msg",
      "results"
  ]
}

TASK [Does the looped result have a stat key] **********************************
ok: [localhost] => {
  "msg": "stat defined at top level: False | results length: 3"
}

changed, failed, msg, results. No stat key.

stats.stat.exists — the expression you would write for a single stat — does not exist. The module’s return values moved one level down, into a list:

Read-only / Safeone entry inside results
$ ansible-playbook loopreg.yml
TASK [Keys of ONE entry inside results] ****************************************
ok: [localhost] => {
  "stats.results[0].keys() | list": [
      "changed",
      "failed",
      "ansible_loop_var",
      "item",
      "stat"
  ]
}

There is stat, one level down, inside results[0]. And two extra keys the loop contributed:

  • item — the loop value this result came from. This is what makes a results list usable: each entry knows which input produced it.
  • ansible_loop_var — the name of the variable that held the item, which is item by default and changes when loop_control sets loop_var. It exists so that code consuming results can find the value without knowing how the loop was written.

Reading a results list

The idiom is a loop over results with a label, because the default label prints each complete result and floods the output:

Read-only / Safeiterating results readably
$ ansible-playbook loopreg.yml
TASK [The item key is carried on each result] **********************************
ok: [localhost] => (item=/etc/hostname) => {
  "msg": "/etc/hostname exists=True"
}
ok: [localhost] => (item=/etc/os-release) => {
  "msg": "/etc/os-release exists=True"
}
ok: [localhost] => (item=/etc/definitely-not-here) => {
  "msg": "/etc/definitely-not-here exists=False"
}
Read-only / Safethe three patterns worth knowing
# 1. Iterate, with a readable label
- name: Report each path
ansible.builtin.debug:
  msg: "{{ item.item }} exists={{ item.stat.exists }}"
loop: "{{ stats.results }}"
loop_control:
  label: "{{ item.item }}"

# 2. Filter to the entries you care about
- name: Which paths are missing
ansible.builtin.debug:
  msg: >-
    missing: {{ stats.results
                | rejectattr('stat.exists')
                | map(attribute='item')
                | list }}

# 3. Extract one field from every entry
- name: Every checksum in one list
ansible.builtin.debug:
  var: stats.results | map(attribute='stat.checksum') | list

Pattern 2 is the one to reach for. results combined with selectattr / rejectattr and map(attribute=...) answers “which hosts or items had property X” in one expression, and it is how a fleet-wide survey play produces a useful report rather than 2,000 lines of raw output.

stdout_lines, and when there is no stdout at all

Where a module ran a process, take stdout_lines over splitting stdout yourself. The module split it on the target, where the line endings came from; a Jinja split('\n') splits on the controller against whatever the string happened to become.

The wider point is that stdout exists at all only for modules that run a process. command, shell, script and raw have it. stat, uri, package, file, service do not — they return structured data instead, which is the whole argument for using them.

A conditional written as when: result.stdout is search('active') is a signal worth noticing in review: it means somebody parsed text out of a shell command where a module was returning a field. service_facts returns a service’s state as a value. uri returns status as an integer. Parsing text is what you do when no module will tell you, and it should feel like a compromise every time.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A task uses ansible.builtin.stat with a loop over three paths and registers the result as stats. What does stats.stat.exists evaluate to?

  2. Q2. You need to know which items in a looped stat result did not exist. Which expression is correct?

  3. Q3. Which keys are present on a registered result only under specific conditions rather than always? Select all that apply.

  4. Q4. On ansible-core 2.21 a registered result carries no invocation key unless INJECT_INVOCATION has been explicitly enabled.

Passing score: 75%. Answers are checked in this browser.