Skip to main content
RunBook Academy

AnsibleXXX · Host Targeting and Blast RadiusNarrowing a run

Zero hosts: warning or error

Intermediate⏱ ~24 minbash

What you'll learn

  • Predict the exit code for each of the three zero-host situations
  • Explain why a bare pattern and a --limit are treated differently
  • Identify the zero-host case that is silent even with --limit
  • Make a scheduled or CI run fail when it targets nothing

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.

There are three ways to end up targeting no hosts, and they do not all behave the same. Getting this straight is what separates “the job is green because it worked” from “the job is green because nothing happened”.

SituationOutputExit code
The play’s hosts: pattern matches nothingWarning, skipping: no hosts matched0
--limit matches nothing in the inventory[ERROR] ... leaves us with no hosts to target1
--limit matches a real host outside the play’s hosts:skipping: no hosts matched0

Two of those exit 0. One of those two is the reason CI jobs report success for weeks while doing nothing.

Case one: a bare pattern that matches nothing

Read-only / Safethe play targets a group that does not exist
$ ansible-playbook -i inventory/hosts.yml nohosts.yml; echo exit=$?
[WARNING]: Could not match supplied host pattern, ignoring: nosuchgroup

PLAY [Target a group that does not exist] **************************************
skipping: no hosts matched

PLAY RECAP *********************************************************************

exit=0

Read the recap. It is empty — not zero-filled, empty. There is no host line to compare against yesterday’s, because there is no host.

A scheduled job in this state produces a log full of nothing and a success every night. Nothing alerts, because nothing failed. The warning is in the log and warnings in a nightly log are not read.

Case two: a --limit that matches nothing

Completely different treatment:

Read-only / Safea typo in the limit
$ ansible-playbook -i inventory/hosts.yml site.yml --limit web01.example.con; echo exit=$?
[WARNING]: Could not match supplied host pattern, ignoring: web01.example.con
[ERROR]: Specified inventory, host pattern and/or --limit leaves us with no hosts to target.
exit=1

This is the behaviour you want, and it is the strongest single argument for making --limit the habitual way to narrow a run: a typo in a --limit fails closed. Nothing happened, the exit code says so, and the scheduler notices.

Case three: the silent one

Now the combination that is neither of the above. The limit is a real host. It is simply not in the play’s target.

Read-only / Safea valid host, outside the play's pattern
$ ansible-playbook -i inventory/hosts.yml site.yml --limit db01.example.com; echo exit=$?
PLAY [Configure the web tier] **************************************************
skipping: no hosts matched

PLAY RECAP *********************************************************************

exit=0

There is not even a warning here. Every term resolved. The set arithmetic produced nothing, which Ansible has no reason to consider remarkable.

Why the asymmetry exists

It is not arbitrary, and understanding it makes the behaviour predictable rather than something to memorise.

A play’s hosts: line is a statement of intent. “Run this on the web servers” is a meaningful instruction in an estate that has no web servers today — the play is correct, there is just nothing to do. A site.yml containing twenty plays, run in an environment where six of those tiers do not exist, should run the fourteen that do rather than refusing. That is only possible if an empty play is survivable.

A --limit is a narrowing of something you have already decided to run. Supplying a limit that matches no host in the inventory is not a description of an empty set; it is almost always a typo. Ansible treats it as one.

Case three sits between the two: the limit resolved, so it was not a typo; the play’s pattern resolved, so it was not wrong either; the intersection was empty, and neither party is at fault. Nothing in the model has an opinion about that, so nothing complains.

Making zero hosts fail

Two layers, because neither catches everything.

Layer one — inside the play — catches “fewer than expected”, not zero. An assertion in a pre-flight play refuses an implausible count:

Read-only / Safea floor assertion
- name: Pre-flight - the target must be plausible
hosts: web
gather_facts: false
vars:
  expected_min_hosts: 2
tasks:
  - name: Refuse an implausibly small target
    run_once: true
    ansible.builtin.assert:
      that:
        - ansible_play_hosts_all | length >= expected_min_hosts | int
      fail_msg: >-
        Targeting {{ ansible_play_hosts_all | length }} hosts, expected at
        least {{ expected_min_hosts }}.

It cannot catch zero. With no hosts in the play there is nothing for the task to run on, so the assertion never executes and the play reports skipping: no hosts matched exactly as before. That is not a flaw in the assertion — it is a structural consequence of tasks running on hosts.

Layer two — outside the play — is what catches zero. Resolve the pattern first, read the count, and refuse before ansible-playbook is invoked at all:

Read-only / Saferun-change.sh - refuse an empty target
#!/usr/bin/env bash
set -euo pipefail

PATTERN=${1:?usage: run-change.sh <pattern>}
FLOOR=${FLOOR:-1}

count=$(ansible -i inventory/ "$PATTERN" --list-hosts       | sed -n 's/^ *hosts (\([0-9]*\)):.*/\1/p')

if [ -z "$count" ] || [ "$count" -lt "$FLOOR" ]; then
echo "refusing: pattern '$PATTERN' resolved to ${count:-0} hosts, floor is $FLOOR" >&2
exit 1
fi

echo "$PATTERN resolves to $count hosts; proceeding"
ansible-playbook -i inventory/ site.yml --limit "$PATTERN"
Read-only / Safethe wrapper refusing, for two different reasons
$ run-change.sh nosuchgroup; FLOOR=20 run-change.sh web
[WARNING]: Could not match supplied host pattern, ignoring: nosuchgroup
[WARNING]: No hosts matched, nothing to do
refusing: pattern 'nosuchgroup' resolved to 0 hosts, floor is 1
rc=1

refusing: pattern 'web' resolved to 4 hosts, floor is 20
rc=1
  1. For anything scheduled, wrap it. The wrapper resolves the pattern, reads the count, and exits non-zero below the floor - which is the only layer that catches zero hosts.
  2. Set the floor from a snapshot rather than a guess. "At least one" catches the typo; "at least 90% of last week" catches the partial inventory.
  3. Add the in-play assertion as well, because it catches the case the wrapper cannot: a per-play intersection that empties in a multi-play site.yml where the overall count looked fine.
  4. Alert on a run whose changed count has been zero for longer than usual. A patching job that changes nothing three weeks running is either perfectly converged or not running at all, and those need to be distinguishable.
  5. Prefer --limit over editing the hosts: line for one-off narrowing, because a typo in a limit is an error and a typo in a pattern is a warning.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A nightly job has reported success for three weeks and changed nothing. Investigation shows the group named in the play hosts: line was renamed. What did the job do each night?

  2. Q2. Why is an unmatched --limit a hard error when an unmatched play pattern is not?

  3. Q3. Which situations produce zero hosts and exit code 0? Select all that apply.

  4. Q4. An assert task inside the play can be used to fail a run that targeted zero hosts.

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