This is part 12 of the Ansible for network engineers series. Part 8 used the resource modules' parsed state, which works well when a module exists for what you want, and that mostly means config. A lot of what you actually check day to day isn't config, though. Interface state, uptime, software version, whether NTP is synced: that all lives in show commands, and no resource module will hand it to you as data. That's what ansible.utils.cli_parse is for.
This post does three real jobs on the six IOS XE devices from part 10 and part 11: a down-interface report, a version and uptime inventory, and an operational compliance rule built from a custom template.
Built in my home lab with ansible-core 2.21.5, ansible.utils 6.1.1, cisco.ios 11.6.0, ansible.netcommon 8.7.1, textfsm 2.1.0, ntc_templates 9.3.0, ttp 0.10.1 and Python 3.14, against Cisco IOS XE 17.18.2 (router and L2 lab images).

Parsers and what they need
cli_parse runs a command, or takes text you already have, and passes it to a parser engine. The engines are Python libraries installed next to Ansible, not collections:
pip install textfsm ntc_templates ttp
| Parser name | Library | Templates |
|---|---|---|
| ansible.netcommon.ntc_templates | ntc_templates | 979 TextFSM templates in 9.3.0 (143 for IOS), picked by platform and command |
| ansible.utils.textfsm | textfsm | Your own TextFSM file |
| ansible.utils.ttp | ttp | Your own TTP file, which looks like the output it parses |
| ansible.netcommon.pyats | pyats, genie | Cisco's Genie parsers |
I also wanted Genie in this comparison. pip install "pyats[library]" failed on Python 3.14 because the ruamel.yaml.clib wheel wouldn't build, so it's not in this post. If you need Genie today, run it from a Python 3.12 virtual environment.
Job 1: which interfaces are down, and why
show ip interface brief has an ntc template, so the parser picks it from the platform and command name. With set_fact, the result lands as a list of dicts, one per interface:
# playbooks/120-down-interfaces.yml
---
- name: Find interfaces that are down on every device
hosts: sites
gather_facts: false
tasks:
- name: Run show ip interface brief and parse it with ntc-templates
ansible.utils.cli_parse:
command: show ip interface brief
parser:
name: ansible.netcommon.ntc_templates
set_fact: ipb
- name: Split the down interfaces by why they are down
ansible.builtin.set_fact:
admin_down: "{{ ipb | selectattr('status', 'eq', 'administratively down') | map(attribute='interface') | list }}"
broken: "{{ ipb | selectattr('status', 'eq', 'up') | rejectattr('proto', 'eq', 'up') | map(attribute='interface') | list }}"
- name: Report
ansible.builtin.debug:
msg: "up/down: {{ broken }} | admin down: {{ admin_down }}"
- name: One table for the whole run
ansible.builtin.copy:
dest: ../reports/down-interfaces.md
mode: "0644"
content: |
| Device | Up, protocol down | Admin down |
|---|---|---|
{% for h in ansible_play_hosts | sort %}
| {{ h }} | {{ hostvars[h].broken | join(', ') or '-' }} | {{ hostvars[h].admin_down | join(', ') or '-' }} |
{% endfor %}
delegate_to: localhost
run_once: true
I split the two cases on purpose. Admin down is a decision somebody made. Up with protocol down is a fault. A single "down" list mixes the two, and people learn to ignore it.
My first run found nothing up/down, which is true but makes a dull example. So on hq-edge1 I added a GRE tunnel, Tunnel99, sourced from Loopback0 and pointed at 10.66.99.99, an address nothing answers on. That's the classic leftover from a decommissioned DR site, and IOS shows it as up/down. The rerun:
TASK [Report] ******************************************************************
ok: [hq-core1] => {
"msg": "up/down: [] | admin down: ['Ethernet0/3']"
}
ok: [hq-core2] => {
"msg": "up/down: [] | admin down: ['Ethernet0/1', 'Ethernet0/2', 'Ethernet0/3']"
}
ok: [br1-edge1] => {
"msg": "up/down: [] | admin down: ['Ethernet0/1', 'Ethernet0/2', 'Ethernet0/3']"
}
ok: [hq-edge1] => {
"msg": "up/down: ['Tunnel99'] | admin down: ['Ethernet0/1', 'Ethernet0/2', 'Ethernet0/3']"
}
ok: [br1-acc1] => {
"msg": "up/down: [] | admin down: []"
}
ok: [br2-edge1] => {
"msg": "up/down: [] | admin down: ['Ethernet0/1', 'Ethernet0/2', 'Ethernet0/3']"
}
And the report it wrote:
| Device | Up, protocol down | Admin down |
|---|---|---|
| br1-acc1 | - | - |
| br1-edge1 | - | Ethernet0/1, Ethernet0/2, Ethernet0/3 |
| br2-edge1 | - | Ethernet0/1, Ethernet0/2, Ethernet0/3 |
| hq-core1 | - | Ethernet0/3 |
| hq-core2 | - | Ethernet0/1, Ethernet0/2, Ethernet0/3 |
| hq-edge1 | Tunnel99 | Ethernet0/1, Ethernet0/2, Ethernet0/3 |
One row to look at, and it's the right one. I removed the tunnel afterwards.
The field names (interface, status, proto) come from the template, not from Ansible. Before you filter on anything, print the parsed result once and check the key names, as the next job does.
Job 2: an inventory report from show version
Same parser, different command. This time the playbook prints one device's parsed output before using it:
# playbooks/121-inventory.yml
---
- name: Inventory report from show version
hosts: sites
gather_facts: false
tasks:
- name: Parse show version with ntc-templates
ansible.utils.cli_parse:
command: show version
parser:
name: ansible.netcommon.ntc_templates
set_fact: ver
register: sv
until: sv is not failed
retries: 3
delay: 10
- name: Show what the parser returned for one device
ansible.builtin.debug:
var: ver
run_once: true
- name: Write a CSV for the whole run
ansible.builtin.copy:
dest: ../reports/inventory.csv
mode: "0644"
content: |
device,version,uptime,hardware,image
{% for h in ansible_play_hosts | sort %}
{% set v = hostvars[h].ver[0] %}
{{ h }},{{ v.version }},"{{ v.uptime }}",{{ v.hardware | join(' ') }},{{ v.software_image }}
{% endfor %}
delegate_to: localhost
run_once: true
What came back for hq-core1:
ok: [hq-core1] => {
"ver": [
{
"config_register": "0x0",
"hardware": [],
"hostname": "hq-core1",
"mac_address": [],
"release": "fc3",
"reload_reason": "Unknown reason",
"restarted": "",
"rommon": "Bootstrap",
"running_image": "/iol/iol.bin",
"serial": [
"4"
],
"software_image": "X86_64BI_LINUX-ADVENTERPRISEK9-M",
"uptime": "5 hours, 42 minutes",
"uptime_days": "",
"uptime_hours": "5",
"uptime_minutes": "42",
"uptime_weeks": "",
"uptime_years": "",
"version": "17.18.2"
}
]
}
hardware is an empty list, because the lab image doesn't print a model line the template recognises. On a Catalyst 9300 or an ISR you get the model there. Your report code has to cope with an empty field either way, which is why the CSV uses join and doesn't index [0]. Second, uptime comes back both as a string and split into parts. Use the parts if you want to flag "rebooted in the last day" without parsing English.
The CSV:
device,version,uptime,hardware,image
br1-acc1,17.18.2,"5 hours, 42 minutes",,X86_64BI_LINUX_L2-ADVENTERPRISEK9-M
br1-edge1,17.18.2,"5 hours, 42 minutes",,X86_64BI_LINUX-ADVENTERPRISEK9-M
br2-edge1,17.18.2,"5 hours, 42 minutes",,X86_64BI_LINUX-ADVENTERPRISEK9-M
hq-core1,17.18.2,"5 hours, 42 minutes",,X86_64BI_LINUX-ADVENTERPRISEK9-M
hq-core2,17.18.2,"5 hours, 42 minutes",,X86_64BI_LINUX-ADVENTERPRISEK9-M
hq-edge1,17.18.2,"5 hours, 42 minutes",,X86_64BI_LINUX-ADVENTERPRISEK9-M
The image column is how you'd spot br1-acc1 as the switching image here.
A correction on the until loop. The first two runs without it lost br1-acc1 to a "timed out" from cli_parse, and the run that got all six didn't actually retry. I found later that in ansible.utils 6.1.1 a cli_parse retry can't work: every attempt after the first fails with "parameters are mutually exclusive: command|text". If a device is slow, poll it with ios_command and until, then parse once. Either way, check that the row count matches the host count, because a missing row in an inventory report is easy to miss.
Job 3: a template of your own, feeding a compliance rule
Part 11 checked that every device has the right NTP server configured. That's a config fact. Whether the device is actually synced is an operational fact, and only show ntp associations knows it. Here's the raw output from hq-core1:
address ref clock st when poll reach delay offset disp
~10.66.0.123 .TIME. 16 - 64 0 0.000 0.000 15937.
* sys.peer, # selected, + candidate, - outlyer, x falseticker, ~ configured
ntc_templates 9.3.0 has no IOS template for this command, so I wrote one in TTP. A TTP template is a copy of the line you want, with variables where the values go:
# templates/ios_show_ntp_associations.ttp
<group name="peers*">
{{ peer }} {{ refclock }} {{ stratum | to_int }} {{ when }} {{ poll | to_int }} {{ reach }} {{ delay }} {{ offset }} {{ disp }}
</group>
The * on the group name forces a list even when there's a single peer. Without it, one server comes back as a dict and two come back as a list, and your Jinja breaks the day someone adds a second server. The header and legend lines don't match the pattern, so they're dropped.
The tally character (*, ~, + and so on) stays stuck to the address, because IOS prints them with no space between. I left it in the data and strip it in the rule, since the tally is what tells you which peer is selected.
The playbook builds two rules in the same want/have shape as part 11, so the same report template renders them:
# playbooks/122-ntp-state.yml
---
- name: NTP sync state as a compliance rule
hosts: sites
gather_facts: false
tasks:
- name: Parse show ntp associations with a TTP template
ansible.utils.cli_parse:
command: show ntp associations
parser:
name: ansible.utils.ttp
template_path: ../templates/ios_show_ntp_associations.ttp
set_fact: ntp_state
register: np
until: np is not failed
retries: 3
delay: 10
- name: Show the parsed peers for one device
ansible.builtin.debug:
var: ntp_state
run_once: true
- name: Build config and operational rules from the parsed peers
ansible.builtin.set_fact:
rules:
- rule: ntp-configured
want: "{{ [ntp_server] }}"
have: "{{ peers | map(attribute=\"peer\") | map(\"regex_replace\", \"^[*#+x~-]+\", \"\") | list }}"
- rule: ntp-synced
want: "{{ [ntp_server] }}"
have: "{{ peers | selectattr(\"peer\", \"match\", \"^[*]\") | selectattr(\"stratum\", \"lt\", 16) | map(attribute=\"peer\") | map(\"regex_replace\", \"^[*#+x~-]+\", \"\") | list }}"
vars:
peers: "{{ ntp_state[0][0].peers | default([]) }}"
- name: Mark ok per rule
ansible.builtin.set_fact:
report: "{{ report | default([]) + [item | combine({\"ok\": (item.want | sort) == (item.have | sort)})] }}"
loop: "{{ rules }}"
loop_control:
label: "{{ item.rule }}"
- name: Show failures for this device
ansible.builtin.debug:
msg: "{{ report | rejectattr(\"ok\") | map(attribute=\"rule\") | list }}"
- name: Write one report for the run
ansible.builtin.template:
src: ../templates/compliance.md.j2
dest: ../reports/ntp-state.md
mode: "0644"
delegate_to: localhost
run_once: true
A peer only counts as synced if its tally is * (sys.peer) and its stratum is below 16. Stratum 16 means unsynchronised in NTP, so checking it as well catches a peer that's selected but useless.
The parsed data, now a list thanks to the *:
ok: [hq-core1] => {
"ntp_state": [
[
{
"peers": [
{
"delay": "0.000",
"disp": "15937.",
"offset": "0.000",
"peer": "~10.66.0.123",
"poll": 64,
"reach": "0",
"refclock": ".TIME.",
"stratum": 16,
"when": "-"
}
]
TTP nests its results two lists deep, hence ntp_state[0][0]. And the report:
| Device | Rule | Result | Want | Have |
|---|---|---|---|---|
| br1-acc1 | ntp-configured | pass | 10.66.20.123 | 10.66.20.123 |
| br1-acc1 | ntp-synced | FAIL | 10.66.20.123 | |
| br1-edge1 | ntp-configured | pass | 10.66.20.123 | 10.66.20.123 |
| br1-edge1 | ntp-synced | FAIL | 10.66.20.123 | |
| br2-edge1 | ntp-configured | pass | 10.66.20.123 | 10.66.20.123 |
| br2-edge1 | ntp-synced | FAIL | 10.66.20.123 | |
| hq-core1 | ntp-configured | pass | 10.66.0.123 | 10.66.0.123 |
| hq-core1 | ntp-synced | FAIL | 10.66.0.123 | |
| hq-core2 | ntp-configured | pass | 10.66.0.123 | 10.66.0.123 |
| hq-core2 | ntp-synced | FAIL | 10.66.0.123 | |
| hq-edge1 | ntp-configured | pass | 10.66.0.123 | 10.66.0.123 |
| hq-edge1 | ntp-synced | FAIL | 10.66.0.123 |
I didn't stage this one. Part 11's check came back all pass, and every device really is configured correctly. But neither NTP server address has anything listening in my lab, so reach is 0 everywhere and no device has a sys.peer. A config-only audit would have signed this off. Logs and certificates on all six devices are running on unsynced clocks, and only the parsed show output says so.
The filter, and why I wouldn't start with it
Older playbooks parse with a filter on registered output rather than with cli_parse. It still works:
# playbooks/123-filter.yml
- name: Run the command once
cisco.ios.ios_command:
commands: show ip interface brief
register: out
- name: Parse it with the parse_cli_textfsm filter
ansible.builtin.debug:
msg: "{{ out.stdout[0] | ansible.netcommon.parse_cli_textfsm(tpl) | selectattr(\"PROTO\", \"ne\", \"up\") | map(attribute=\"INTERFACE\") | list }}"
vars:
tpl: "{{ playbook_dir }}/../templates/cisco_ios_show_ip_interface_brief.textfsm"
TASK [Parse it with the parse_cli_textfsm filter] ******************************
[WARNING]: The 'parse_cli_textfsm' filter is deprecated and will be removed in a future release after 2027-02-01. Use 'ansible.utils.cli_parse' instead.
ok: [hq-edge1] => {
"msg": [
"Ethernet0/1",
"Ethernet0/2",
"Ethernet0/3",
"Tunnel99"
]
}
Three things tripped me up. The filter is deprecated, as the warning says. It returns the raw TextFSM value names, so the keys are uppercase (PROTO, INTERFACE) where cli_parse with ntc_templates gave lowercase. My first attempt used lowercase keys and failed with "object of type 'dict' has no attribute 'proto'". And the template path is resolved from where you run ansible-playbook rather than the playbook, which is why it uses playbook_dir. For new work I use cli_parse.
Picking a parser
| Situation | Use |
|---|---|
| A resource module covers it (config) | The module's gathered or parsed state, as in part 8 |
| Show command with an ntc template | ansible.netcommon.ntc_templates |
| No template exists, output is a simple table | ansible.utils.ttp with your own template |
| Complex multi section output, your team knows TextFSM | ansible.utils.textfsm with your own template |
| You need Genie's coverage | ansible.netcommon.pyats, on a Python version it supports |
To check if an ntc template exists, look in the package's templates/index file for your platform and command.
Reliability notes
I ran all of this with ansible_command_timeout: 120 and ansible_network_cli_ssh_type: paramiko, the same settings as part 11. cli_parse opens the same network_cli connection as any other IOS task, so the same timeouts apply. br1-acc1 was the device that timed out in job 2. The retry loops in the playbooks above don't help, as the correction under job 2 explains.
Part 13 uses these parsers for pre and post change checks on OSPF and BGP. The rest of the series is on the series page.