> ## Content Index
> Fetch the complete content index at: https://www.pinglabz.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Parsing IOS CLI Output with Ansible: cli_parse, TextFSM and TTP
- URL: https://www.pinglabz.com/ansible-parse-cli-textfsm-ttp-ios/
- Published: 2026-10-31T23:50:26.000Z
- Updated: 2026-11-01T04:41:36.000Z
- Description: Three jobs on six IOS XE devices with ansible.utils.cli_parse: a down-interface report, a version and uptime inventory, and a custom TTP template that turns show ntp associations into a compliance rule. Every device passed the config check and failed the sync check.
- Author: Jaime
- Tags: Network Automation, Cisco IOS XE, Cisco

This is part 12 of the [Ansible for network engineers series](https://www.pinglabz.com/ansible-network-automation/). [Part 8](https://www.pinglabz.com/ansible-gathered-rendered-parsed-ios/) 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](https://www.pinglabz.com/ansible-ios-vlans-trunks-svis-resource-modules/) and [part 11](https://www.pinglabz.com/ansible-ios-compliance-drift-remediation/): 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).

[![Lab diagram: one IOS XE 17.18.2 device's show ip interface brief, show version and show ntp associations output flows into ansible.utils.cli_parse. The ntc_templates parser (TextFSM, 979 templates, 143 for IOS) produces down-interfaces.md, highlighting hq-edge1 Tunnel99 up/down, and inventory.csv with device, version, uptime, hardware and image columns. A custom ttp template produces ntp-state.md: ntp-configured passes on 6 of 6 devices but ntp-synced fails on 6 of 6. pyats/Genie is greyed out, not installed on Python 3.14.](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/ansible-cli-parse-textfsm-ttp.png)](https://storage.ghost.io/c/ff/28/ff28db4a-11e1-4835-b928-158974cf96c1/content/images/2026/11/ansible-cli-parse-textfsm-ttp.png?ref=pinglabz.com)

From show output to data with cli\_parse, TextFSM and TTP

## 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:

```bash
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:

```yaml
# 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:

```text
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:

```yaml
# 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:

```text
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:

```text
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:

```text
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:

```text
# 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:

```yaml
# 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 `*`:

```text
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:

```yaml
# 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"

```

```text
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](https://www.pinglabz.com/ansible-ospf-bgp-ios/) uses these parsers for pre and post change checks on OSPF and BGP. The rest of the series is on the [series page](https://www.pinglabz.com/ansible-network-automation/).