AnsibleXXV · Check Mode, Diff and Static ValidationCheck mode, diff and static validation
Designing a play whose dry run is informative
What you'll learn
- Assess a play before a change window for how much of it a dry run will evaluate
- Make a command task predictable in check mode with a creates or removes guard
- Restructure a sequential play so its dry run reaches further
- Decide when a play cannot be usefully dry-run and needs a canary instead
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 previous lesson is easy to read as an argument against check mode. It is not. It is an argument that how much a dry run tells you is a property of the play, and properties of plays can be changed.
The question to ask before a change window is not “does Ansible support
check mode”. It is: for this play, on this fleet, what fraction of the
tasks will --check actually evaluate, and are the important ones in
it?
Measuring before you rely on it
Two commands answer it, neither of which touches a host.
$ ansible-playbook -i inv.ini site.yml --list-tasks$ for m in package template systemd command copy lineinfile; do printf '%-12s %s\n' "$m" "$(ansible-doc -j ansible.builtin.$m | jq -r '.[].doc.attributes.check_mode.support')"; donepackage N/A
template full
systemd N/A
command partial
copy full
lineinfile fullIllustrative output
Then run the check and compare. If the play has four command tasks and
the recap says skipped=4, the check told you nothing about the four
tasks that do the work. That is a one-minute assessment on a Tuesday,
and the alternative is discovering it during the change.
Fix 1: creates and removes on command
command has partial check-mode support, and the word is misleading —
the behaviour is binary. With a creates or removes guard the module
evaluates the file and returns a genuine prediction. Without one it
returns a skip.
$ ansible-playbook -i localhost, guards.yml --checkTASK [A command with no guard] *************************************************
skipping: [localhost]
TASK [A command with a creates guard on a file that exists] ********************
ok: [localhost]
TASK [A command with a creates guard on a file that does not exist] ************
changed: [localhost]
TASK [Results] *****************************************************************
ok: [localhost] => {
"msg": [
"unguarded skipped=True",
"guarded_exists changed=False skipped=False",
"guarded_missing changed=True skipped=False"
]
}The middle task predicted no change; the third predicted a change. Both are real answers. The first is silence.
- name: Run the schema migration once
ansible.builtin.command: /usr/local/bin/migrate.sh
args:
creates: '/var/lib/app/.migrated-{{ schema_version }}'
This is the best of the fixes because it improves both modes at once. The guard makes the task idempotent for real runs, which is what you wanted anyway, and check mode gets a prediction as a side effect. A migration script that has already run is not run again, in either mode.
Fix 2: check_mode: false on genuine probes
Covered mechanically two lessons ago; here it is as a design move.
- name: Read the currently deployed version
ansible.builtin.command: /usr/local/bin/app --version
register: app_version
changed_when: false
check_mode: false
- name: Upgrade only if the deployed version is older
ansible.builtin.package:
name: 'app-{{ target_version }}'
state: present
when: app_version.stdout is version(target_version, '<')
Without check_mode: false, the probe is skipped, app_version.stdout
is the empty string, the version comparison silently takes a branch that
has nothing to do with reality, and the check run reports on a decision
the real run will not make. With it, the conditional evaluates against
the truth and the check output is about the change you are actually
proposing.
The changed_when: false next to it is not decoration. A probe that
reports changed inflates the change count of every real run and, worse,
fires handlers.
Fix 3: move the prerequisite out of the play
The sequential-dependency problem is often a structural one wearing a check-mode costume.
# Before: one play, three dependencies deep
- hosts: appservers
tasks:
- name: Install the application package
ansible.builtin.package: { name: app, state: present }
- name: Write the configuration
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf # directory created by the package
- name: Start the service
ansible.builtin.systemd_service: { name: app, state: started }
A check run of that reports a predicted install, a failure on the
template because /etc/app does not exist yet, and never reaches the
service.
# After: provisioning is a separate concern that has already run
- hosts: appservers
roles:
- app_baseline # package and directories - run at build time
- hosts: appservers
tasks:
- name: Write the configuration
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf
notify: Restart app
On a fleet where app_baseline has already run, the second play is fully
checkable: template has full check-mode support, the directory exists,
and --check --diff produces exactly the artefact a reviewer wants.
The separation is better design for reasons that have nothing to do with check mode — the deployment play stops re-deciding provisioning questions on every run — and a checkable dry run comes free. That is the usual shape: plays that dry-run well are plays with clear dependency boundaries, and check mode is a diagnostic for a structural property rather than a feature to be configured.
Fix 4: tolerate the check failure without weakening the real run
Sometimes a task genuinely cannot succeed in check mode and the play is
correct as written. The documented pattern templates ignore_errors from
the magic variable:
- name: Verify the rendered config parses
ansible.builtin.command: /usr/sbin/nginx -t
changed_when: false
check_mode: false
ignore_errors: "{{ ansible_check_mode }}"
$ ansible-playbook -i localhost, tolerate.yml --check; echo "exit=$?"TASK [A task that tolerates failure only during a check run] *******************
fatal: [localhost]: FAILED! => {"changed": true, "cmd": ["/bin/false"], "msg": "The command exited with a non-zero return code.", "rc": 1}
...ignoring
exit=0$ ansible-playbook -i localhost, tolerate.yml; echo "exit=$?"PLAY RECAP *********************************************************************
localhost : ok=4 changed=2 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
exit=2ignored=0 and failed=1 in the real run: the tolerance applies only to
the dry run. This is a narrow, honest tool — and it is one line away from
the dishonest version, a bare ignore_errors: true, which weakens the
real run as well. Write the template form or write neither.
Fix 5: accept that this play needs a canary
Some plays cannot be usefully dry-run and saying so is the professional answer. A play is in this category when:
- most of its work is
command,shellorscriptagainst tooling with no module, and the tasks genuinely cannot carrycreatesguards; - its logic branches on results that only exist after real changes;
- the risk is second-order — the config is trivially correct and the question is whether the service survives it.
For those, the dry run is not the control. A canary is: run against
one host under --limit, verify the outcome directly, then widen. The
verification part of this course builds that sequence, and the next
lesson assembles the whole ladder including the point where the canary
takes over from the check run.
Deciding this deliberately, and writing it in the runbook, is worth a great deal more than a check run that everybody knows is uninformative and nobody says so.
Knowledge check
Knowledge check · 4 questions
Q1. Adding creates: /var/lib/app/.migrated to a command task is described as the best of the check-mode fixes. Why?
Q2. A check run of a play reports skipped=4. Which of these would you do before the change window? Select all that apply.
Q3. ignore_errors: "{{ ansible_check_mode }}" tolerates a task failure during a dry run while leaving the real run to fail normally.
Q4. A play is mostly shell tasks against a vendor CLI with no module and no once-only markers, and its logic branches on their output. What is the right conclusion?
Passing score: 75%. Answers are checked in this browser.