Skip to main content
RunBook Academy

AnsibleXV · Conditionals and LoopsConditionals

Conditioning on a previous task

Intermediate⏱ ~22 minansible-playbook

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

Not yet marked complete on this device.

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

TestTrue when
is failedThe task failed
is succeededThe task succeeded
is skippedThe task was skipped
is changedThe 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.

Read-only / Safethe shape of a dependent task
- 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.exists

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

Read-only / Safewhat a skipped task registers
$ ansible-playbook skipreg.yml
TASK [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:

Read-only / Safethe tests are safe on a skipped result
$ ansible-playbook skipreg.yml
TASK [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:

Read-only / Safereading a module key that is not there
$ ansible-playbook skipreg.yml
TASK [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.

Read-only / Saferc as data, not as an outcome
- 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 == 1

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

Read-only / Safefragile, then robust
# 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 skipped

For a chain longer than two tasks, hoist the condition into a block so it is stated once:

Read-only / Safeone condition, several tasks
- 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

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

  2. Q2. Which conditional expresses "the previous task ran and completed successfully"?

  3. Q3. Which statements about rc and failed on a registered result are accurate? Select all that apply.

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