Skip to main content
Ansible · lesson 6 of 6
Ansible advanced Lesson 6 of 6

Check Mode's Blind Spot: command Skips, copy Simulates, check_mode: false Writes Anyway

A --check dry run isn't uniform: command tasks skip, copy safely simulates changed, and check_mode: false writes for real. Verified against real files.

By SkillByExample · Published

ansible check-mode testing dry-run

--check is often described as “a dry run” as if every module behaves the same way under it. They don’t — three genuinely different behaviors, each verified against the actual filesystem state afterward.

command and shell don’t simulate — they skip

# checkmode_command.yml
---
- name: Check mode with command module
  hosts: local
  tasks:
    - name: Touch a file with command
      command: touch /tmp/checkmode_test.txt
rm -f /tmp/checkmode_test.txt
ansible-playbook -i inventory.ini checkmode_command.yml --check
ls -la /tmp/checkmode_test.txt
TASK [Touch a file with command] ***********************************************
skipping: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0

ls: cannot access '/tmp/checkmode_test.txt': No such file or directory

Not changed, not ok — skipping. Ansible has no way to know what touch would actually do to an arbitrary command, so under --check it doesn’t try: the task is skipped outright, and skipped=1 shows up in the recap instead of a real prediction. A playbook built on command/shell gives a dry run nothing meaningful to report.

copy is check-mode aware — it simulates safely

# checkmode_handler2.yml
---
- name: Check mode with a copy-based handler
  hosts: local
  tasks:
    - name: Write a config file
      copy:
        content: "new config value\n"
        dest: /tmp/checkmode_config.txt
      notify: Rewrite marker file
  handlers:
    - name: Rewrite marker file
      copy:
        content: "marker written\n"
        dest: /tmp/checkmode_marker.txt
rm -f /tmp/checkmode_marker.txt
ansible-playbook -i inventory.ini checkmode_handler2.yml --check
ls -la /tmp/checkmode_marker.txt
TASK [Write a config file] *****************************************************
changed: [localhost]

RUNNING HANDLER [Rewrite marker file] ******************************************
changed: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=3    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

ls: cannot access '/tmp/checkmode_marker.txt': No such file or directory

Two things worth noticing together: notify still fires its handler under --check — the handler isn’t skipped just because the whole run is a dry run — and copy (used both by the notifying task and by the handler) is check-mode aware enough to report changed=2 while writing nothing at all. ls confirms the marker file genuinely doesn’t exist.

check_mode: false quietly opts a task out of the dry run

# checkmode_force.yml
---
- name: Force real execution under check mode
  hosts: local
  tasks:
    - name: Always actually write this file
      copy:
        content: "written even under check mode\n"
        dest: /tmp/checkmode_forced.txt
      check_mode: false
rm -f /tmp/checkmode_forced.txt
ansible-playbook -i inventory.ini checkmode_force.yml --check
cat /tmp/checkmode_forced.txt
TASK [Always actually write this file] *****************************************
changed: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

written even under check mode

cat proves it: the file exists, with real content, after a run invoked with --check. A changed: [localhost] line here looks identical to every simulated changed above it — the only way to know this one is real is to have read the task’s check_mode: false line first. --check is not a hard guarantee against side effects; it’s a per-task convention that any individual task can opt out of.

Practice

1. Run the command example without --check and confirm it behaves completely differently — no skipping at all.
rm -f /tmp/checkmode_test.txt
ansible-playbook -i inventory.ini checkmode_command.yml
ls -la /tmp/checkmode_test.txt
TASK [Touch a file with command] ***********************************************
changed: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

-rw-r--r-- 1 root root 0 Sep 14 08:50 /tmp/checkmode_test.txt

Without --check, command runs normally and reports changed like any other task — the skipping: behavior only exists specifically because a dry run has no way to predict what an arbitrary shell command would do.

2. Add --diff to the copy-based handler example and see what it shows for a brand-new file versus a modified one.
rm -f /tmp/checkmode_marker.txt
ansible-playbook -i inventory.ini checkmode_handler2.yml --check --diff
TASK [Write a config file] *****************************************************
--- before: /tmp/checkmode_config.txt
+++ after: /tmp/checkmode_config.txt
@@ -1 +1 @@
-old config value
+new config value

changed: [localhost]

RUNNING HANDLER [Rewrite marker file] ******************************************
--- before
+++ after: /tmp/checkmode_marker.txt
@@ -0,0 +1 @@
+marker written

changed: [localhost]

A file that already exists gets a real before/after diff; a file that doesn’t exist yet shows an empty before and the new content as after — --diff distinguishes “this file will be modified” from “this file will be created” even though both report the same changed.

3. Try check_mode: false on the command task from example 1 and confirm it now runs for real under --check too.
# checkmode_command_forced.yml
---
- name: Force command to run under check mode
  hosts: local
  tasks:
    - name: Touch a file with command
      command: touch /tmp/checkmode_test2.txt
      check_mode: false
rm -f /tmp/checkmode_test2.txt
ansible-playbook -i inventory.ini checkmode_command_forced.yml --check
ls -la /tmp/checkmode_test2.txt
TASK [Touch a file with command] ***********************************************
changed: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

-rw-r--r-- 1 root root 0 Sep 14 08:50 /tmp/checkmode_test2.txt

check_mode: false overrides the module-level default in both directions: it turns command’s usual skipping: into a real run just as readily as it turned copy’s safe simulation into a real write in the main example.

4. Run the roles lesson's webserver role under --check and compare it to a real run to see whether anything differs.
ansible-playbook -i inventory.ini role_playbook.yml --check
TASK [webserver : Create config directory] *************************************
ok: [localhost]

TASK [webserver : Write config file] *******************************************
ok: [localhost]

TASK [webserver : Touch a marker file] *****************************************
changed: [localhost]

RUNNING HANDLER [webserver : Reload config] ************************************
ok: [localhost] => {
    "msg": "Config reloaded on port 8080"
}

PLAY RECAP *********************************************************************
localhost                  : ok=5    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Running the same playbook again without --check produces an identical recap: ok for the directory and the templated config file (both already match, and both are check-mode aware enough to know it), changed for the marker task, and the handler fires and prints the same message either way. Nothing here distinguishes the dry run from the real one — which is exactly the point: a role built entirely from file, template, and copy gives --check an honest answer, unlike the command-based example earlier in this lesson.

Idempotency, precedence, vault, roles, and control flow are covered in the lessons before this one — --check/--diff is the tool for previewing any of that against a real target before committing to it for real.

Frequently Asked Questions

Does ansible-playbook --check actually run every task?
No. Modules that know how to predict their own effect (like copy or template) simulate and report changed or ok without writing. Modules that can't predict a shell command's effect (like command or shell) skip the task entirely under --check, showing skipping: rather than a real changed/ok verdict.
Do handlers fire during a --check run?
Yes — a notify still triggers its handler under --check, and if the handler's module supports check mode (like copy), the handler itself is simulated: it reports changed but performs no real write. Verified directly by checking the target file did not exist after the run.
What does check_mode: false do on a task?
It forces that one task to always execute for real, even when the whole playbook is run with --check. This is meant for tasks a dry run genuinely can't simulate safely (like a value lookup), but it also means a --check run is not a hard guarantee of zero side effects — verify which tasks opt out before trusting a dry run completely.
If command and shell skip under --check, how do I dry-run a playbook that uses them?
You generally can't get a meaningful simulation of a command or shell task's effect from --check — it will report skipping: regardless of what the command would do. The usual approach is to replace ad-hoc commands with a purpose-built module (copy, file, apt, service, etc.) wherever one exists, since those modules are check-mode aware and command/shell are the deliberate escape hatch for a real system probe.
Does --diff show file changes for every module, or just copy/template?
--diff shows a line-level before/after for modules that support it, which includes copy, template, and several others that write file content. Modules that only report a boolean changed without tracking content (many command-wrapping or API-calling modules) have nothing for --diff to show, so it prints no diff block even though changed is still true.