Running a playbook with --check --diff before the real thing is one of the cheapest habits in network automation. It is also easy to trust too much. On IOS XE, check mode never talks to the device's parser, so it can only tell you what Ansible would send, not whether the router will take it. This post runs one small change through check mode, a real run and a second check, and then shows two cases where check mode said "changed" and the real run failed.
It was built in my home lab on the six IOS XE lab routers from the earlier parts, using the three HQ routers: hq-core1, hq-core2 and hq-edge1. The previous post was part 18, IOS XE ACLs with the ios_acls resource module.
Versions: ansible-core 2.21, cisco.ios 11.6, IOS XE 17.18.2.

The change
Two settings on the HQ routers: a 64000 byte informational logging buffer, and an SSH timeout of 60 seconds instead of 30. One uses a resource module, one uses ios_config, and the last task saves the config. Mixing them is deliberate, because they behave differently in check mode.
---
- name: Logging buffer and SSH timeout on HQ
hosts: hq
gather_facts: false
tasks:
- name: Buffered logging through the resource module
cisco.ios.ios_logging_global:
config:
buffered:
size: 64000
severity: informational
state: merged
- name: SSH timeout through ios_config
cisco.ios.ios_config:
lines:
- ip ssh time-out 60
diff_against: running
- name: Save the config
cisco.ios.ios_command:
commands:
- write memory
Before the run, all three routers had ip ssh time-out 30 and no logging buffered line.
The dry run
--check --diff -v so the module results are printed. hq-core1 shown, the other two matched:
TASK [Buffered logging through the resource module] ****************************
changed: [hq-core1] => {"after": {"hosts": [{"host": "10.66.0.51"}], "trap": "informational"}, "before": {"hosts": [{"host": "10.66.0.51"}], "trap": "informational"}, "changed": true, "commands": ["logging buffered 64000 informational"]}
TASK [SSH timeout through ios_config] ******************************************
[WARNING]: unable to perform diff against running-config due to check mode
changed: [hq-core1] => {"banners": {}, "changed": true, "commands": ["ip ssh time-out 60"], "updates": ["ip ssh time-out 60"]}
TASK [Save the config] *********************************************************
[WARNING]: Only show commands are supported when using check mode, not executing write memory
ok: [hq-core1] => {"changed": false, "stdout": [], "stdout_lines": []}
PLAY RECAP *********************************************************************
hq-core1 : ok=3 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Three things in there are worth reading closely.
The resource module gives you commands, not a diff. commands is the useful part: exactly the line it would push. The after key in check mode is a copy of before, so it does not show the predicted end state. --diff printed nothing extra for this task. If you want to review a resource module change, read commands.
diff_against: running does nothing in check mode. The module says so in a warning. You still get updates, which is the list of lines it would send, but the before and after config diff only appears on a real run.
ios_command does not run a config command in check mode. It does not execute write memory. It warns "Only show commands are supported when using check mode" and reports ok. That is the right behaviour, but it means a check run never proves your save step works, and a later task that reads the registered output of that command gets an empty stdout.
The real run
Same playbook, --diff only. This is where the ios_config diff appears. hq-core1's hunk, with two management lines trimmed from the context:
TASK [SSH timeout through ios_config] ******************************************
--- before
+++ after
@@ -54,7 +54,7 @@
ip http secure-server
ip ssh bulk-mode 131072
-ip ssh time-out 30
+ip ssh time-out 60
no logging btrace
logging host 10.66.0.51
changed: [hq-core1]
PLAY RECAP *********************************************************************
hq-core1 : ok=3 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-core2 : ok=3 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-edge1 : ok=3 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The resource module task printed changed with no diff, as in check mode. The changed counts matched the dry run exactly. On the real run, write memory ran, and the startup config on hq-core1 afterwards had both lines:
ip ssh time-out 60
logging buffered 64000 informational
Check again after the change
Running the same --check --diff a second time is the cheapest idempotency test there is:
PLAY RECAP *********************************************************************
hq-core1 : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-core2 : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
hq-edge1 : ok=3 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Zero changed. If a task stays changed here, the line you wrote does not match how the device stores it, and that task will report a change on every run.
Where check mode and the real run disagree
ios_config in check mode compares your lines against the running config as text. It never enters config mode, so the IOS parser never sees the command. Two lines that look plausible:
---
- name: Where check mode and the real run disagree
hosts: hq-core1
gather_facts: false
tasks:
- name: A typo the device will reject
cisco.ios.ios_config:
lines:
- ntp server 10.66.0.124 prefered
- name: Old syntax this release rejects
cisco.ios.ios_config:
lines:
- no ip domain-lookup
The first has prefered for prefer. The second is the hyphenated form that older IOS accepted and still turns up in a lot of copied baselines. This router already stores no ip domain lookup, without the hyphen.
Check mode says both are fine:
TASK [A typo the device will reject] *******************************************
changed: [hq-core1]
TASK [Old syntax this release rejects] *****************************************
changed: [hq-core1]
PLAY RECAP *********************************************************************
hq-core1 : ok=2 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The real run:
TASK [A typo the device will reject] *******************************************
fatal: [hq-core1]: FAILED! => {"changed": false, "msg": "Task failed: b\"ntp server 10.66.0.124 prefered\\r\\nntp server 10.66.0.124 prefered\\r\\n ^\\r\\n% Invalid input detected at '^' marker.\\r\\n\\r\\nhq-core1(config)#\""}
And with --start-at-task to get past the first failure:
TASK [Old syntax this release rejects] *****************************************
fatal: [hq-core1]: FAILED! => {"changed": false, "msg": "Task failed: b\"no ip domain-lookup\\r\\nno ip domain-lookup\\r\\n ^\\r\\n% Invalid input detected at '^' marker.\\r\\n\\r\\nhq-core1(config)#\""}
The second one is the more useful lesson. The router already had the setting the line was trying to apply. Check mode could not know that because the text did not match, so it reported a change. The real run found out the line is not valid syntax on 17.18 at all.
In both cases nothing was applied, because IOS rejects the line before it changes anything. On a multi-line ios_config task that is not guaranteed, because the lines are sent one at a time and any before the bad one are already in the running config when it fails. Check mode will not warn you about that either.
In practice
- Read
commandsandupdateswith-v, not just the changed count. They are the actual lines that will be sent. - Expect no config diff from resource modules, and no
diff_againstdiff fromios_config, until the real run. - Check mode does not validate syntax. Test new lines on one device first with
--limit, then roll out. - Run check mode again after the change. Anything still
changedis a line written differently from how the device stores it. - Tasks that depend on
ios_commandoutput get nothing in check mode. Guard them withwhen: not ansible_check_modeor give them a default.
Where to take it
Check mode fits well as the first stage of a pipeline: run --check --diff -v on every change, save the output as the review artifact, and only allow the real run after someone has read the commands lists. The real run's diff then becomes the record of what actually changed.
Part 20 The next part, [standardizing and hardening services on the IOS routers](https://www.pinglabz.com/ansible-services-hardening-ios/), pushes NTP, syslog, SNMPv3, banners and AAA to every device. The rest of the series is on the series page.