Skip to main content
RunBook Academy

AnsibleXI · PlaybooksPlaybooks

Reading the recap, and the exit code

Intermediate⏱ ~18 minansible-playbook

What you'll learn

  • Define every field of the PLAY RECAP and what it says about that host
  • Map a run outcome to the exit code ansible-playbook returns
  • Explain why a CI job testing only for exit code 2 can pass a failed run
  • Recognise why a controller-side task cannot detect an unreachable host

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.

The PLAY RECAP is the only summary of a run that Ansible produces on its own, and the exit code is the only part of it a machine can read. Between them they answer “what just happened to my estate”, and almost every automation pipeline that has ever gone green on a failed run got one of the two wrong.

The seven fields

Read-only / Safeevery recap field populated in a single play
$ ansible-playbook -i inv2.ini vocab.yml
PLAY RECAP *********************************************************************
localhost                  : ok=6    changed=1    unreachable=0    failed=0    skipped=1    rescued=1    ignored=1

One line per host that the run attempted. Read it left to right:

FieldCountsWhat it tells you about that host
okTasks that ran and did not failIncludes both “changed something” and “checked and it was already correct”. changed is a subset of it in the sense that a changed task is also a successful one, but the two counters are reported separately.
changedTasks that reported a state changeThe audit answer to “what did this run alter”. Its accuracy is the subject of Part XII.
unreachableTasks that could not connectAn infrastructure fact, not a playbook fact. The host was not talked to.
failedTasks that failed and were not rescued or ignoredThe playbook ran and the host said no.
skippedTasks whose when: was false, or which lacked check-mode support in a --check runTwo very different causes, one counter.
rescuedTasks that failed inside a block and were handled by its rescueThe failure happened and was dealt with. Not a clean run.
ignoredTasks that failed under ignore_errors: trueThe failure happened and was discarded.

Two of those deserve emphasis.

skipped conflates two causes. A task skipped by a false when: and a task skipped because the module does not support check mode produce identical output. In a --check run, skipped is partly a measure of how much the prediction did not cover.

rescued and ignored are not zero-cost. A recap reporting failed=0 rescued=3 describes a run in which three tasks failed. The run recovered; the estate may not have. Treating failed=0 as “clean” is how a degraded run gets recorded as a success.

unreachable is not failed

These are different questions about different systems, and conflating them sends you to debug the wrong thing.

  • failed=1 — Ansible connected, ran a module, and the module reported failure. The playbook, the host’s state, or the module’s arguments are at fault.
  • unreachable=1 — Ansible could not establish a usable connection. SSH refused, the host is down, DNS did not resolve, the key was rejected, or the Python interpreter could not be started. Nothing in your playbook is implicated.
Read-only / Safea genuinely unreachable host
$ ansible-playbook -i inv2.ini ec-unreach.yml
fatal: [web01.example.com]: UNREACHABLE! => {"changed": false, "msg": "Task failed: Failed to connect to the host via ssh: ssh: connect to host 192.0.2.11 port 22: Connection timed out", "unreachable": true}

PLAY RECAP *********************************************************************
web01.example.com          : ok=0    changed=0    unreachable=1    failed=0    skipped=0    rescued=0    ignored=0

The pre-flight check that proves nothing

Here is a reachability check that a lot of people have written:

- name: Confirm the fleet is reachable
  hosts: all
  gather_facts: false
  tasks:
    - name: Confirm the host answers
      ansible.builtin.debug:
        msg: 'reached {{ inventory_hostname }}'

Run against the same unreachable host as above, on the same inventory:

Read-only / Safea reachability check built from debug
$ ansible-playbook -i inv2.ini preflight-debug.yml
TASK [Confirm the host answers] ************************************************
ok: [web01.example.com] => {
  "msg": "reached web01.example.com"
}

PLAY RECAP *********************************************************************
web01.example.com          : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

ok=1. unreachable=0. Exit code 0. A completely green run against a host that does not exist.

The exit code

The recap is for humans. Cron, CI and the wrapper script read the exit code, and it has one property that catches people out.

Run outcomeExit code
Success0
Task failure only2
Unreachable host only4
Task failure and unreachable host4

All four rows verified by execution on ansible-core 2.21.3. The exit-code table is not published in the upstream CLI documentation or the ansible-playbook(1) manual page; these values come from observed behaviour and from the installed source, and the version they were established against is stated deliberately.

The last row is the one that matters. Unreachable takes precedence:

Read-only / Safeboth failure kinds in one run
$ ansible-playbook -i inv2.ini ec-both.yml; echo EXIT=$?
fatal: [localhost]: FAILED! => {"changed": false, "msg": "deliberate failure"}

PLAY RECAP *********************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0
web01.example.com          : ok=0    changed=0    unreachable=1    failed=0    skipped=0    rescued=0    ignored=0

EXIT=4
Read-only / Safethe only correct shape
ansible-playbook -i inventory.ini site.yml
rc=$?
if [ "$rc" -ne 0 ]; then
echo "ansible-playbook exited $rc"
exit "$rc"
fi

Preserving the exact code on the way out is worth the extra word: 4 tells whoever reads the job log that at least one host was unreachable, which is an infrastructure page rather than a playbook bug.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A run has twelve hosts that failed a task and three that were unreachable. What exit code does ansible-playbook return?

  2. Q2. A play whose only task is ansible.builtin.debug is run against a host that is powered off. What does the recap report for that host?

  3. Q3. A recap reads: ok=9 changed=2 unreachable=0 failed=0 skipped=3 rescued=2 ignored=1. Which statements are true? Select all that apply.

  4. Q4. A host that appears in the inventory but not in the PLAY RECAP was reachable and simply had no work to do.

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