This is part 8 of the Ansible for network engineers series. Part 3 covered the states that change a device: merged, replaced, overridden and deleted. The same resource modules have three more states that never push a single command. gathered reads live config into structured data. rendered turns structured data into CLI lines without touching a device. parsed reads a saved config file and returns the same structure gathered would.
That makes them the safe way to start automating a network you didn't build. You can read everything, store it as YAML, and compare it before any playbook is allowed to change anything.
Built in my home lab with ansible-core 2.21.5, cisco.ios 11.6.0, ansible.netcommon 8.7.1 and Python 3.14, against hq-core1 running IOS XE 17.18.2. The inventory is the one from part 2.

gathered: live state as YAML
# playbooks/80-gathered.yml
---
- name: Read device state as structured data
hosts: hq-core1
gather_facts: false
tasks:
- name: Gather L3 interfaces
cisco.ios.ios_l3_interfaces:
state: gathered
register: l3
- name: Gather interfaces
cisco.ios.ios_interfaces:
state: gathered
register: intf
- name: Save both as YAML
ansible.builtin.copy:
content: "{{ {'l3_interfaces': l3.gathered, 'interfaces': intf.gathered} | to_nice_yaml(indent=2) }}"
dest: "{{ playbook_dir }}/../build/gathered/{{ inventory_hostname }}.yml"
mode: "0644"
delegate_to: localhost
The only changed in the run is the file write on the control node. The router reports ok:
TASK [Gather L3 interfaces] ****************************************************
ok: [hq-core1]
TASK [Gather interfaces] *******************************************************
ok: [hq-core1]
TASK [Save both as YAML] *******************************************************
changed: [hq-core1 -> localhost]
PLAY RECAP *********************************************************************
hq-core1 : ok=3 changed=1 unreachable=0 failed=0 skipped=0
Here's the file it wrote:
interfaces:
- description: OOB management
enabled: true
name: Ethernet0/0
- description: 'CORE: hq-core2 Et0/1'
enabled: true
name: Ethernet0/1
- description: 'CORE: hq-edge1 Et0/1'
enabled: true
name: Ethernet0/2
- description: TEMP lab test - remove
enabled: false
name: Ethernet0/3
- description: HQ core loopback
enabled: true
name: Loopback0
l3_interfaces:
- ipv4:
- address: 10.66.10.11/24
name: Ethernet0/0
- ipv4:
- address: 10.66.101.1/30
name: Ethernet0/1
- ipv4:
- address: 10.66.199.1/24
secondary: true
- address: 10.66.102.1/30
name: Ethernet0/2
- name: Ethernet0/3
- ipv4:
- address: 10.66.255.11/32
name: Loopback0
Reading it as data showed two things I'd forgotten on this router: Et0/3 is shut with a "TEMP lab test - remove" description, and Et0/2 still has a 10.66.199.1/24 secondary from an old test. Both were in show run the whole time. I just never looked. In YAML they're one grep away, and on a real network you can loop the same play over every host and search the lot.
Masks come back as prefix lengths (/30, not 255.255.255.252). That's the format the module expects in config:, so this output can go straight back into a replaced task later.
rendered: YAML to CLI with no device
rendered takes the same config: you'd give merged or replaced and returns the commands it would generate. It never opens a session, so it runs on localhost.
# playbooks/81-rendered.yml
---
- name: Render config from data, no device needed
hosts: localhost
gather_facts: false
vars:
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.ios.ios
want: "{{ lookup('file', playbook_dir ~ '/../build/gathered/hq-core1.yml') | from_yaml }}"
tasks:
- name: Render L3 interfaces
cisco.ios.ios_l3_interfaces:
state: rendered
config: "{{ want.l3_interfaces }}"
register: r
- name: Show it
ansible.builtin.debug:
var: r.rendered
The two connection vars look odd for a play that connects to nothing, but my first run without them failed:
Connection type local is not valid for this module
Implicit localhost gets ansible_connection: local, and the module refuses to run under it even though it won't use the connection. A play level connection: keyword didn't help, because the host var wins. Setting both as play vars did.
ok: [localhost] => {
"r.rendered": [
"interface Ethernet0/0",
"ip address 10.66.10.11 255.255.255.0",
"interface Ethernet0/1",
"ip address 10.66.101.1 255.255.255.252",
"interface Ethernet0/2",
"ip address 10.66.102.1 255.255.255.252",
"ip address 10.66.199.1 255.255.255.0 secondary",
"interface loopback0",
"ip address 10.66.255.11 255.255.255.255"
]
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0
Two details. The prefixes are back to dotted masks, which is what IOS wants. And Loopback0 came out as interface loopback0 in lowercase. IOS accepts that, but if you diff rendered output against show run text, normalize case first or every loopback shows up as a difference.
I use rendered for code review: put the generated lines in the merge request so whoever approves the change reads CLI, not YAML.
parsed: a saved config file, offline
Part 4 saved every router's running config to backups/. parsed reads that text and returns structured data, again with no device.
# playbooks/82-parsed.yml
---
- name: Parse saved config text, no device needed
hosts: localhost
gather_facts: false
vars:
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.ios.ios
tasks:
- name: Parse interfaces from a backup file
cisco.ios.ios_interfaces:
state: parsed
running_config: "{{ lookup('file', playbook_dir ~ '/../backups/hq-core1-interfaces.cfg') }}"
register: p
- name: Show name and description
ansible.builtin.debug:
msg: "{{ p.parsed | map(attribute='name') | zip(p.parsed | map(attribute='description', default='')) | list }}"
My first attempt pointed it at the full backup file and failed:
Action failed: 'name' is undefined
I cut the file down until it passed. The problem was a VRF definition near the top of the config (name changed here):
vrf definition MGMT
description management VRF
The interfaces parser picked up that indented description line as if it belonged to an interface, found no interface name to attach it to, and failed. Feeding it only the interface sections fixed it:
awk '/^interface /{p=1} /^!/{p=0} p' backups/hq-core1.cfg > backups/hq-core1-interfaces.cfg
# output compacted to one pair per line
ok: [localhost] => {
"msg": [
["Ethernet0/0", "OOB management"],
["Ethernet0/1", "CORE: hq-core2 Et0/1"],
["Ethernet0/2", "CORE: hq-edge1 Et0/2"],
["Ethernet0/3", "TEMP lab test - remove"],
["Loopback0", "HQ core loopback"]
]
}
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0
Compare that with the gathered file. Live Et0/2 says CORE: hq-edge1 Et0/1. The backup says CORE: hq-edge1 Et0/2. The description was corrected on the router after that backup was taken, so the backup is stale. Nothing in ansible-playbook output warned me; the two data sets only disagreed once they were side by side.
Which one to use
| State | Talks to device | Input | Output |
|---|---|---|---|
| gathered | yes, read only | live config | structured data |
| rendered | no | structured data | CLI lines |
| parsed | no | config text | structured data |
All three report changed=0 for the device, so they're safe in a scheduled job or against production on day one.
What I'd do on a production network
Start with gathered across the whole inventory and commit the YAML to git next to the backups from part 4. Read it. You'll find leftovers like my Et0/3 and that secondary address. Use parsed on old backups when the device is gone or you can't log in, and strip non interface sections first if the parser chokes. Use rendered to show reviewers the exact commands before anything is pushed. Only after the data looks right should it become the config: of a replaced task.
Next in the series: Ansible for Cisco ISE: Network Devices, Device Groups and Endpoints with cisco.ise, managing network devices, device groups and endpoints over the ERS API.