AnsibleXXV · Check Mode, Diff and Static ValidationCheck mode, diff and static validation
The cheapest gates: syntax and listing
What you'll learn
- State exactly which defect classes --syntax-check catches and which it passes
- Explain why a dynamic include is invisible to every static gate
- Use ansible-inventory and ansible-config to answer questions a playbook cannot
- Read the exit code of a static gate and know what it distinguishes
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
The playbook part introduced the pre-flight ladder as a habit. This part is about what each rung is worth as evidence, because a validation step whose limits you cannot state is not a control — it is a source of confidence, and confidence is the thing that gets fleets broken.
The static gates share one property that makes them worth running on every commit: they open no connection, touch no host, and cost milliseconds. They also share a second property that is stated much less often: most of what can be wrong with a playbook survives all of them.
What --syntax-check catches
The way to find out is to write playbooks that are wrong in one specific way each, and run the gate. Every result below was produced on ansible-core 2.21.3.
| The defect | --syntax-check | Exit |
|---|---|---|
| Invalid YAML (bad indentation, unquoted colon) | Caught | 4 |
A module name that does not resolve — ansible.builtin.tempalte | Caught | 4 |
A typo in a task keyword — when_ever: instead of when: | Caught, reported as conflicting action statements | 4 |
A role named in roles: that does not exist | Caught | 1 |
import_tasks naming a file that does not exist | Caught | 1 |
| An inventory file that does not parse | Caught | 120 |
An option that does not exist on a real module — onwer: on copy | Passed | 0 |
| A variable that is defined nowhere | Passed | 0 |
groups['cache'] where no cache group exists | Passed | 0 |
template whose src: file does not exist | Passed | 0 |
include_tasks naming a file that does not exist | Passed | 0 |
The top half is more than the option is usually given credit for. The bottom half is the part to memorise.
$ ansible-playbook -i inv.ini broken.yml --syntax-check; echo "exit=$?"playbook: broken.yml
exit=0Compare with the module-name typo, which is caught:
$ ansible-playbook -i inv.ini typo.yml --syntax-check; echo "exit=$?"[ERROR]: couldn't resolve module/action 'ansible.builtin.tempalte'. This often indicates a misspelling, missing collection, or incorrect module path.
Origin: /home/ops/plays/typo.yml:15:7
13 msg: "{{ undefined_everywhere }}"
14
15 - name: A module that does not exist
^ column 7
exit=4The dynamic-include blind spot
One row in that table has consequences beyond itself:
$ ansible-playbook -i inv.ini dynamic.yml --syntax-check && ansible-playbook -i inv.ini dynamic.yml --list-tasksplaybook: dynamic.yml
playbook: dynamic.yml
play #1 (webservers): p TAGS: []
tasks:
ansible.builtin.include_tasks TAGS: []include_tasks is resolved when the task runs, so at parse time there is
nothing to check and at listing time there is nothing to list. The
listing shows the include task itself and stops.
This is not a bug — dynamic includes exist precisely so that the file
name can be computed from a variable — but it means every static gate
is blind in proportion to how much of the repository is dynamically
included. A playbook built from include_role and include_tasks can
produce a four-line --list-tasks output for a run that executes three
hundred tasks against two hundred hosts, and a clean --syntax-check for
a repository whose task files were deleted.
import_tasks and import_role are static, appear in the listing, and
are validated at parse time. Where the choice between them is otherwise
free, that is a real argument for the static form.
--list-hosts answers “which”, never “what”
$ ansible-playbook -i inv.ini site.yml --list-hostsplaybook: site.yml
play #1 (webservers): A playbook that parses and cannot possibly work TAGS: []
pattern: ['webservers']
hosts (2):
web01.example.com
web02.example.comThe number in hosts (n) is the one figure from the static gates that
belongs in a change record. It is also the one most often misread, in a
specific way: it is the set of hosts the play would target, not the
set that would be changed. A play targeting two hundred hosts of
which one is out of compliance changes one host and lists two hundred.
Its second limitation is temporal. With a dynamic inventory, the answer is the answer now. Between the listing and the run, an autoscaling group can add hosts, and the run will include them.
ansible-inventory answers questions the playbook cannot
--list-hosts tells you the result of a pattern.
ansible-inventory tells you the structure the pattern was resolved
against, which is what you need when the result is surprising.
$ ansible-inventory -i inv.ini --graph --vars@all:
|--@ungrouped:
|--@webservers:
| |--web01.example.com
| | |--{ansible_host = 192.0.2.11}
| |--web02.example.com
| | |--{ansible_host = 192.0.2.12}
|--@dbservers:
| |--db01.example.com
| | |--{ansible_host = 192.0.2.21}$ ansible-inventory -i inv.ini --host web01.example.com{
"ansible_host": "192.0.2.11"
}The one it does not answer is precedence involving anything outside the
inventory — -e, role defaults, set_fact, registered results. Those
are decided at run time and the variables part covers reading them with
-v and debug.
An unparseable inventory is caught, with a distinctive exit code:
$ ansible-inventory -i broken.ini --graph; echo "exit=$?"[WARNING]: Failed to parse inventory with 'ini' plugin: Failed to parse inventory: not enough values to unpack (expected 3, got 2)
Failed to parse inventory with 'ini' plugin.
exit=120ansible-config dump answers “which settings are not the defaults”
Behaviour differences between two controllers are usually configuration, and configuration is the least visible thing in a repository because most of it is implicit.
$ ansible-config dump --only-changedCONFIG_FILE() = NoneOn a controller with no ansible.cfg the answer is almost empty, which
is itself the finding — this controller is running entirely on defaults.
Run the same command on the machine where the playbook behaves
differently and the difference is usually in the diff between the two
outputs.
The full dump prints every setting with its source annotated:
$ ansible-config dump | grep -E '^(DEFAULT_FORKS|DEFAULT_TIMEOUT|HOST_KEY_CHECKING|DEFAULT_GATHERING)'DEFAULT_FORKS(default) = 5
DEFAULT_GATHERING(default) = implicit
DEFAULT_TIMEOUT(default) = 10
HOST_KEY_CHECKING(default) = TrueWhat it does not validate is whether those values are appropriate.
ansible-config will happily report a setting that makes your runs
unsafe; it reports configuration, not judgement.
Knowledge check
Knowledge check · 4 questions
Q1. On ansible-core 2.21.3, which of these passes --syntax-check with exit code 0?
Q2. Which defects survive both --syntax-check and --list-tasks? Select all that apply.
Q3. The hosts (n) figure from --list-hosts is the number of hosts the run would change.
Q4. A CI job runs ansible-playbook --syntax-check and fails with exit code 120. What is the most likely cause?
Passing score: 75%. Answers are checked in this browser.