Skip to main content
RunBook Academy

AnsibleXXXVI · Drift and ConvergenceDrift and convergence

What check mode cannot tell you

Advanced⏱ ~24 minansible-playbookansible-doc

What you'll learn

  • Predict which tasks are skipped under --check by reading a module check_mode attribute
  • Explain why a gate on a registered return code opens in check mode even though the command never ran
  • Use check_mode: false correctly, and recognise when it makes a dry run change production
  • Write conditionals that refuse to decide rather than deciding on absent evidence

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 turned a playbook into a drift report with one flag. This lesson is about the ways that report lies to you, all of which have been reproduced on ansible-core 2.21.3 rather than inferred.

They matter more than the mechanics, because a drift report you trust and should not is worse than no drift report at all.

Support is per-module, and it has three values

Every module documents a check_mode attribute, and it takes one of three values:

Read-only / Safeask the module, do not assume
ansible-doc ansible.builtin.command | sed -n '/ATTRIBUTES/,/NOTES/p'
ansible-doc ansible.builtin.wait_for | sed -n '/ATTRIBUTES/,/NOTES/p'
ansible-doc ansible.builtin.template | sed -n '/ATTRIBUTES/,/NOTES/p'

Read on 2.21.3:

Modulecheck_mode supportBehaviour under --check
template, copy, file, lineinfile, stat, setup, package_facts, service_factsfullCompares and predicts correctly
commandpartialSkipped unless creates or removes is given
wait_fornoneAlways skipped

command being partial rather than none is a distinction worth having. Its documentation states that the command itself cannot be subject to check-mode semantics, so creates/removes are offered as a workaround — and they work:

Read-only / Safecommand with creates, under --check
$ ansible-playbook -i inventory.ini creates.yml --check
TASK [command with creates, marker absent] *************************************
changed: [localhost]

TASK [command with creates, marker present] ************************************
ok: [localhost]

Without creates or removes, the same module is skipped. Which brings us to the trap.

The trap: a skipped command still registers a result

This is the most important verified fact in this part.

Read-only / Safewhat a check-skipped command registers
$ ansible-playbook -i inventory.ini probe.yml --check
TASK [dump] ********************************************************************
ok: [localhost] => {
  "probe": {
      "changed": false,
      "cmd": ["/bin/echo", "probe"],
      "delta": null,
      "end": null,
      "failed": false,
      "msg": "Command would have run if not in check mode",
      "rc": 0,
      "skipped": true,
      "start": null,
      "stderr": "",
      "stderr_lines": [],
      "stdout": "",
      "stdout_lines": []
  }
}

Look at rc. It is 0. The command never ran, and the registered result says it returned success.

Now consider the two gates every playbook contains:

Read-only / Safeboth naive gates are wrong, in opposite directions
$ ansible-playbook -i inventory.ini gates.yml --check
TASK [probe] *******************************************************************
skipping: [localhost]

TASK [naive gate on rc] ********************************************************
ok: [localhost] => {
  "msg": "NAIVE GATE OPENED"
}
  • when: probe.rc == 0opens. The play proceeds as though the probe had succeeded, on evidence that does not exist.
  • when: "'running' in probe.stdout"closes, silently, because stdout is "". Every task behind it is skipped.
  • probe.stdout | default('unknown') — does not help. stdout exists and is the empty string, so default() never fires.

Writing conditionals that refuse

The fix is not a cleverer expression. It is to make the play refuse to decide when the evidence is missing.

Read-only / Safeguard on skipped, then on the value
- name: probe the current version
ansible.builtin.command:
  cmd: /usr/local/bin/appctl version
register: probe
changed_when: false

- name: refuse to continue if the probe did not actually run
ansible.builtin.fail:
  msg: >-
    The version probe was skipped, so this play cannot decide anything.
    Re-run without --check, or give the probe check_mode: false.
when: probe is skipped

- name: act on the probe result
ansible.builtin.debug:
  msg: "version is {{ probe.stdout }}"
when:
  - probe is not skipped
  - probe.rc == 0
Read-only / Safethe guarded play under --check
$ ansible-playbook -i inventory.ini guard.yml --check
TASK [probe] *******************************************************************
skipping: [localhost]

TASK [guarded gate] ************************************************************
skipping: [localhost]

TASK [explicit refusal when the probe did not run] *****************************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "probe was skipped; this play cannot decide anything in check mode"}

A failed check run is a vastly better outcome than a clean one that examined nothing. It is visible, it is actionable, and it cannot be mistaken for evidence.

check_mode: false: the trap in the other direction

The keyword exists for a real need: a read-only probe should run even during a dry run, because otherwise the dry run cannot compute anything.

Read-only / Safecheck_mode: false executes for real during a --check run
$ ansible-playbook -i inventory.ini forced.yml --check
TASK [probe with command (no creates/removes)] **********************************
skipping: [localhost]

TASK [gate on the probe] *******************************************************
ok: [localhost] => {"msg": "probe stdout was: "}

TASK [probe forced to run even in check mode] **********************************
ok: [localhost]

TASK [show forced result] ******************************************************
ok: [localhost] => {"msg": "forced stdout was: forced"}

That is the intended use, and it is correct here because the task only reads.

Read-only / Safeaudit every occurrence in the repository
grep -rn -B 3 'check_mode:[[:space:]]*\(false\|no\|False\)' roles/ playbooks/

wait_for and the tasks that vanish

wait_for carries check_mode: support: none and is skipped in every check run — confirmed by execution.

That is usually harmless and occasionally not. A play that waits for a service port before verifying it, then checks the service, will in check mode skip the wait and immediately check — which is a different play from the one that runs in production, and it can pass when the real one would fail, or vice versa.

The point generalises: check mode does not run the play you wrote. It runs a play with some tasks removed. The removed tasks are the ones whose modules do not support it, and the recap does not distinguish them from tasks skipped by a when.

Read-only / Safehow much of the play does check mode actually cover?
ansible-playbook -i inventory/ site.yml --limit canary01.example.com \
| tail -3 > /tmp/real.recap

ansible-playbook -i inventory/ site.yml --limit canary01.example.com --check \
| tail -3 > /tmp/check.recap

diff /tmp/real.recap /tmp/check.recap

If the check run skips twenty more tasks than the real one, your drift report has twenty blind spots and you now know the number. Put it in the runbook next to the report.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A command task with no creates or removes is skipped under --check and its result is registered as probe. What is probe.rc?

  2. Q2. A nightly --check job has reported changed=0 on every host for a month while the fleet drifted badly. The audit play starts with a command task whose stdout gates the other forty tasks. What happened?

  3. Q3. Which statements about check_mode: false are correct? Select all that apply.

  4. Q4. Comparing the skipped count between a real run and a check run against one canary host tells you how many tasks your drift report cannot examine.

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