How --check Actually Works

Scenario: A change-approval dry run against production appended to an audit log on every host, then failed with "'dict object' has no attribute 'stdout'". The dry run was neither dry nor a run.

New words, in plain English

--check asks every module to predict rather than act. Well-behaved modules - copy, template, file, service, user - compare current state with desired state and report changed or ok without touching anything.

Two behaviours produce nearly all check-mode surprises.

command and shell are skipped in check mode by default. Ansible cannot know whether an arbitrary command is safe, so it declines to run it. That is correct, and it means any task registering output from a command has no result during --check - so a later task referencing result.stdout fails with the dict-attribute error from the scenario.

check_mode: false forces real execution even during a dry run. This exists for genuinely read-only tasks - reading a version, querying a quota - so that later tasks have the value they need. Applied to a task that mutates the host, it silently makes --check destructive, which is exactly the first half of the scenario.

The combination is the correct fix: put check_mode: false on the read-only command (so it always produces a value) and leave the mutating task obeying check mode normally.

Analogy: A dry run is a fire drill. Marking one task check_mode: false because it is 'only a log entry' is deciding to light a small real fire during the drill - and the point of the drill was that nothing burns.

A worked example

# WRONG - the dry run mutates, then crashes
- name: Note the run in the audit log
  ansible.builtin.shell: "date >> /var/log/audit.log"
  check_mode: false          # genuinely executes during --check
  changed_when: true

- name: Read the licensed quota
  ansible.builtin.command: /opt/bin/read-quota.sh
  register: quota            # SKIPPED in check mode -> no stdout

- name: Render the config
  ansible.builtin.copy:
    dest: /etc/app/quota.conf
    content: "max = {{ quota.stdout }}"   # fails during --check

# RIGHT
- name: Note the run in the audit log
  ansible.builtin.shell: "date >> /var/log/audit.log"
  changed_when: false        # a log note is not a config change
                             # (no check_mode override: skipped in --check)

- name: Read the licensed quota   # READ-ONLY, so safe to force
  ansible.builtin.command: /opt/bin/read-quota.sh
  register: quota
  check_mode: false
  changed_when: false

- name: Render the config
  ansible.builtin.copy:
    dest: /etc/app/quota.conf
    content: "max = {{ quota.stdout }}"

--check pairs with --diff to produce the best change ticket available: a line-by-line preview of every file that would change, on every host, generated from the actual configuration rather than from someone's description of it.

The honest test for whether your dry run is real: fingerprint everything the play can touch, run --check, and fingerprint again. If anything moved, --check is lying, and every review that relied on it was worthless.

Warning: check_mode: false on a task that changes anything makes --check destructive. Use it only for genuinely read-only work.
Goal: Put this to work in the ansible-check-mode-safety lab. Open /labs/ansible, pick ansible-check-mode-safety, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.