Ansible · 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
--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.