AnsibleXV · Conditionals and LoopsConditionals
Conditioning on a previous task
What you'll learn
- Use the task result tests instead of reading result keys directly
- Explain why a skipped task reports is succeeded as true
- Distinguish rc from failed and say which one a conditional should read
- Write a chain of dependent tasks that survives the first one being skipped
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
“Do this if that worked” is the most common conditional in operations automation, and Ansible gives you two ways to write it. One survives the situations that actually occur; the other works right up until the day the first task is skipped.
The four tests
Upstream documents four tests for task results:
| Test | True when |
|---|---|
is failed | The task failed |
is succeeded | The task succeeded |
is skipped | The task was skipped |
is changed | The task reported a change |
Each has a negated form (is not failed) and, from 2.1, an alternative
spelling for people who want the grammar to match — success,
failure, change, skip.
- name: Check whether the config file is present
ansible.builtin.stat:
path: /etc/app/app.conf
register: app_conf
- name: Deploy the default configuration
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf
mode: '0644'
when: not app_conf.stat.existsThe important property of the tests is that they work on any result, including results that carry none of the module’s own keys.
The skipped-register trap
This is the failure the curriculum names and it deserves real output.
A task that is skipped still registers a result. That result contains none of the module’s keys:
$ ansible-playbook skipreg.ymlTASK [This task is skipped] ****************************************************
skipping: [localhost]
TASK [What is in a skipped register] *******************************************
ok: [localhost] => {
"probe": {
"changed": false,
"failed": false,
"false_condition": false,
"skip_reason": "Conditional result was False",
"skipped": true
}
}Five keys. changed, failed, false_condition, skip_reason,
skipped. No stat, no rc, no stdout.
The tests still work on it:
$ ansible-playbook skipreg.ymlTASK [Safe tests still work on a skipped result] *******************************
ok: [localhost] => {
"msg": "is skipped=True is failed=False is succeeded=True is changed=False"
}Reading into the result does not:
$ ansible-playbook skipreg.ymlTASK [Reading a key that is not there] *****************************************
[ERROR]: Task failed: A 'when' expression failed: Error while evaluating
conditional: object of type 'dict' has no attribute 'stat'
Origin: skipreg.yml:26:13
24 ansible.builtin.debug:
25 msg: "you will not see this"
26 when: probe.stat.exists
^ column 13
fatal: [localhost]: FAILED!The same applies to result.rc == 0 on a skipped command, which is
the form the curriculum calls out and the one people write most often.
rc is not failed
Both look like “did it work” and they answer different questions.
rc is the exit status of a process the module ran. It exists only for
command, shell, script and raw, and it is data — the module
reports it and does nothing else with it.
failed is the module’s claim about the task outcome, and it is
what Ansible acts on.
They come apart in both directions:
Non-zero rc, task not failed. failed_when: false or a
failed_when expression that excludes this case. Common and correct:
grep exits 1 when it matches nothing, which is an answer rather than
an error.
Zero rc, task failed. A failed_when that inspects output. A
command that prints an error and exits 0 — more common than it should
be — and a failed_when: "'ERROR' in result.stdout" catching it.
ansible-core 2.21 is tightening this. The porting guide deprecates
inferring failure from a non-zero rc with no explicit failed value:
modules “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”.
The direction is clear. Condition on failed and the tests, not on
rc. Read rc when you genuinely need the exit status as data — for
example distinguishing “not found” from “error” — and say so with a
comment.
- name: Is the feature flag present in the config
ansible.builtin.command:
cmd: grep -q '^feature_x=' /etc/app/app.conf
register: flag_check
changed_when: false
# grep: 0 found, 1 not found, 2 real error.
failed_when: flag_check.rc not in [0, 1]
- name: Add the feature flag
ansible.builtin.lineinfile:
path: /etc/app/app.conf
line: 'feature_x=enabled'
mode: '0644'
when: flag_check.rc == 1Note changed_when: false. Part XII established why: command claims
a change on every successful run, and a check task that claims a change
corrupts handlers, the recap and your drift signal. A read-only check
must say so.
Chains that survive
A chain of dependent tasks is where the skipped-register trap bites, because the first link is conditional and the rest assume it ran.
# Fragile. On a non-Debian host, apt_check is skipped and the
# second task fails reading apt_check.rc on a result that has none.
- name: Check for pending updates
ansible.builtin.command:
cmd: /usr/lib/update-notifier/apt-check
register: apt_check
changed_when: false
when: ansible_facts.os_family == 'Debian'
- name: Report
ansible.builtin.debug:
var: apt_check.stdout
when: apt_check.rc == 0
# Robust. Every reader tests the result rather than indexing into it.
- name: Report
ansible.builtin.debug:
var: apt_check.stdout
when:
- apt_check is succeeded
- apt_check is not skippedFor a chain longer than two tasks, hoist the condition into a block
so it is stated once:
- name: Debian-family update handling
when: ansible_facts.os_family == 'Debian'
block:
- name: Check for pending updates
ansible.builtin.command:
cmd: /usr/lib/update-notifier/apt-check
register: apt_check
changed_when: false
failed_when: apt_check.rc not in [0, 1]
- name: Report
ansible.builtin.debug:
var: apt_check.stdout
- name: Record the count
ansible.builtin.set_fact:
pending_updates: "{{ apt_check.stdout | trim }}"Inside the block, every task either ran or the whole block was skipped together. There is no state where the register exists in a partial form, so the readers need no guard at all. This is the structural fix and it is better than adding a test to each reader.
Knowledge check
Knowledge check · 4 questions
Q1. A task registers into probe and is skipped by its when clause. A later task uses when: probe.rc == 0. What happens on that host?
Q2. Which conditional expresses "the previous task ran and completed successfully"?
Q3. Which statements about rc and failed on a registered result are accurate? Select all that apply.
Q4. Wrapping a conditional chain in a block with a single block-level when removes the need to guard each task that reads the registered result.
Passing score: 75%. Answers are checked in this browser.