Skip to main content
RunBook Academy

AnsibleXXV · Check Mode, Diff and Static ValidationCheck mode, diff and static validation

The cheapest gates: syntax and listing

Intermediate⏱ ~24 minansible-playbookansible-inventoryansible-config

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

Not yet marked complete on this device.

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-checkExit
Invalid YAML (bad indentation, unquoted colon)Caught4
A module name that does not resolve — ansible.builtin.tempalteCaught4
A typo in a task keyword — when_ever: instead of when:Caught, reported as conflicting action statements4
A role named in roles: that does not existCaught1
import_tasks naming a file that does not existCaught1
An inventory file that does not parseCaught120
An option that does not exist on a real module — onwer: on copyPassed0
A variable that is defined nowherePassed0
groups['cache'] where no cache group existsPassed0
template whose src: file does not existPassed0
include_tasks naming a file that does not existPassed0

The top half is more than the option is usually given credit for. The bottom half is the part to memorise.

Read-only / Safea playbook that is wrong in four ways and passes
$ ansible-playbook -i inv.ini broken.yml --syntax-check; echo "exit=$?"
playbook: broken.yml
exit=0

Compare with the module-name typo, which is caught:

Read-only / Safean unresolvable module name
$ 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=4

The dynamic-include blind spot

One row in that table has consequences beyond itself:

Read-only / Safea dynamic include of a file that is not there
$ ansible-playbook -i inv.ini dynamic.yml --syntax-check && ansible-playbook -i inv.ini dynamic.yml --list-tasks
playbook: 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”

Read-only / Safethe blast radius, before anything runs
$ ansible-playbook -i inv.ini site.yml --list-hosts
playbook: site.yml

play #1 (webservers): A playbook that parses and cannot possibly work	TAGS: []
  pattern: ['webservers']
  hosts (2):
    web01.example.com
    web02.example.com

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

Read-only / Safethe group tree, with variables
$ 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}
Read-only / Safeevery variable one host will actually see
$ 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:

Read-only / Safean inventory that does not parse
$ 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=120

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

Read-only / Safethe settings this controller does not take from the defaults
$ ansible-config dump --only-changed
CONFIG_FILE() = None

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

Read-only / Safedefaults worth knowing by heart
$ 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) = True

What 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

  1. Q1. On ansible-core 2.21.3, which of these passes --syntax-check with exit code 0?

  2. Q2. Which defects survive both --syntax-check and --list-tasks? Select all that apply.

  3. Q3. The hosts (n) figure from --list-hosts is the number of hosts the run would change.

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