AnsibleXIV · Facts and Registered VariablesRegistered variables
register and the shape of a result
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
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
$ ansible-playbook reg.ymlTASK [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:
$ ansible-playbook reg.ymlTASK [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
| Key | Always present? | Notes |
|---|---|---|
changed | Yes | The module’s claim, not an observation |
failed | Effectively | Absent means false |
msg | Usually | The human-readable reason, especially on failure |
skipped | Only when skipped | Its absence is how you know the task ran |
rc | Only where a process ran | command, shell, script, raw |
stdout / stderr | Only where a process ran | |
stdout_lines / stderr_lines | Alongside stdout / stderr | Pre-split, on the target |
results | Only when the task looped | And 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:
$ 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: strIllustrative 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
- 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: resultFor 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:
$ ansible-playbook loopreg.ymlTASK [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:
$ ansible-playbook loopreg.ymlTASK [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 aresultslist usable: each entry knows which input produced it.ansible_loop_var— the name of the variable that held the item, which isitemby default and changes whenloop_controlsetsloop_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:
$ ansible-playbook loopreg.ymlTASK [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"
}# 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') | listPattern 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
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?
Q2. You need to know which items in a looped stat result did not exist. Which expression is correct?
Q3. Which keys are present on a registered result only under specific conditions rather than always? Select all that apply.
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.