Skip to main content
RunBook Academy

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

Designing a play whose dry run is informative

Advanced⏱ ~24 minansible-playbookansible-doc

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

Not yet marked complete on this device.

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.

Read-only / Safethe modules the play uses
$ ansible-playbook -i inv.ini site.yml --list-tasks
Read-only / Safecheck-mode support for each of them
$ 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')"; done
package      N/A
template     full
systemd      N/A
command      partial
copy         full
lineinfile   full

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

Read-only / Safethree command tasks in one check run
$ ansible-playbook -i localhost, guards.yml --check
TASK [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 }}"
Read-only / Safetolerated in a check run
$ 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
Read-only / Safenot tolerated in a real run
$ 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=2

ignored=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, shell or script against tooling with no module, and the tasks genuinely cannot carry creates guards;
  • 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

  1. Q1. Adding creates: /var/lib/app/.migrated to a command task is described as the best of the check-mode fixes. Why?

  2. Q2. A check run of a play reports skipped=4. Which of these would you do before the change window? Select all that apply.

  3. Q3. ignore_errors: "{{ ansible_check_mode }}" tolerates a task failure during a dry run while leaving the real run to fail normally.

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