The state option on a Cisco resource module decides what Ansible removes, and the module docs describe it in one line each. I wanted to see the actual commands, so I took one router with some messy hand-built interface config, fed the same YAML to cisco.ios.ios_l3_interfaces four times, and reset the router between runs.
Two results surprised me. overridden was about to delete the management IP on Ethernet0/0, the address Ansible was connected through. And replaced added no ip redirects and no ip unreachables to every interface it touched, even though my YAML never mentioned ICMP.
This is part 3 of the Ansible for Network Engineers series. It reuses the inventory from part 2 and the control node from part 1. I built it in my home lab on Cisco IOS XE 17.18.2, with ansible-core 2.21.5 and cisco.ios 11.6.0. All output is from real runs.
The starting point
hq-core1 is the HQ core router from the earlier posts. Its management port, Ethernet0/0, is 10.66.10.11/24 in a management VRF. The three other ports look like a router that people have been editing by hand for a while:

interface Ethernet0/1
description to hq-core2
ip address 10.66.101.1 255.255.255.252
interface Ethernet0/2
description spare - old WAN
ip address 10.66.199.1 255.255.255.0 secondary
ip address 10.66.102.1 255.255.255.252
interface Ethernet0/3
description TEMP lab test - remove
ip address 10.66.250.1 255.255.255.0
shutdown
Ethernet0/2 still carries a secondary address from an old migration, and Ethernet0/3 has a test subnet someone forgot about. I kept a small playbook, 20-seed-brownfield.yml, that pushes exactly this config with ios_config, so every test below starts from the same state.
The intent file
This is what I want the two core-facing ports to look like. Ethernet0/1 gains an IPv6 address. Ethernet0/2 keeps only its /30.
# vars/hq-core1-l3.yml
l3_intent:
- name: Ethernet0/1
ipv4:
- address: 10.66.101.1/30
ipv6:
- address: 2001:db8:66:101::1/64
- name: Ethernet0/2
ipv4:
- address: 10.66.102.1/30
The file doesn't list Ethernet0/0 or Ethernet0/3. That omission is the whole difference between the four states.
One playbook runs it, with the state passed on the command line, and prints the commands list the module returns. That list is the most honest diff you'll get from a resource module: it's what went down the SSH session.
---
- name: ios_l3_interfaces with a chosen state
hosts: hq-core1
gather_facts: false
vars_files:
- ../vars/{{ l3_file | default('hq-core1-l3.yml') }}
tasks:
- name: L3 interfaces, state={{ st }}
cisco.ios.ios_l3_interfaces:
config: "{{ l3_intent }}"
state: "{{ st }}"
register: result
- name: Commands Ansible sent (or would send)
ansible.builtin.debug:
var: result.commands
I also tried --diff. With these modules it printed nothing extra for me, so result.commands plus a show run before and after is how I compared the runs.
merged: add, never remove
$ ansible-playbook -i site-inv playbooks/21-l3-state.yml -e st=merged --diff
TASK [L3 interfaces, state=merged] *********************************************
changed: [hq-core1]
TASK [Commands Ansible sent (or would send)] ***********************************
ok: [hq-core1] => {
"result.commands": [
"interface Ethernet0/1",
"ipv6 address 2001:db8:66:101::1/64"
]
}
One line. The secondary address on Ethernet0/2 is still there, because merged only adds what's missing. It treats the YAML as "at least this", so leftovers survive. A second run sent nothing and reported changed=0.
Use merged when you're adding something to a brownfield device and you don't yet trust your data to describe everything on it.
replaced: own the listed interfaces
$ ansible-playbook -i site-inv playbooks/21-l3-state.yml -e st=replaced
TASK [L3 interfaces, state=replaced] *******************************************
changed: [hq-core1]
TASK [Commands Ansible sent (or would send)] ***********************************
ok: [hq-core1] => {
"result.commands": [
"interface Ethernet0/1",
"no ip redirects",
"no ip unreachables",
"no ipv6 redirects",
"no ipv6 unreachables",
"ipv6 address 2001:db8:66:101::1/64",
"interface Ethernet0/2",
"no ip redirects",
"no ip unreachables",
"no ipv6 redirects",
"no ipv6 unreachables",
"no ip address 10.66.199.1 255.255.255.0 secondary"
]
}
The secondary address is gone, which is what replaced is for: for every interface in the YAML, the YAML is the complete L3 config. Ethernet0/0 and Ethernet0/3 weren't touched.
The ICMP lines were the part I didn't expect. My YAML didn't set redirects or unreachables, and the module read "not set" as "false". The running config afterwards:
interface Ethernet0/1
description to hq-core2
ip address 10.66.101.1 255.255.255.252
no ip redirects
no ip unreachables
ipv6 address 2001:DB8:66:101::1/64
no ipv6 redirects
no ipv6 unreachables
On a core link that may even be what you want, since plenty of hardening guides turn redirects off. But with unreachables off, the router stops answering with ICMP unreachable messages, and traceroute through it gets harder to read. Ping Fails: ICMP Troubleshooting shows how much those messages tell you during an outage. It should be a decision you make on purpose, so I put the values in the data:
- name: Ethernet0/1
redirects: true
unreachables: true
ipv4:
- address: 10.66.101.1/30
ipv6:
- address: 2001:db8:66:101::1/64
TASK [Commands Ansible sent (or would send)] ***********************************
ok: [hq-core1] => {
"result.commands": [
"interface Ethernet0/1",
"ip redirects",
"ip unreachables",
"interface Ethernet0/2",
"ip redirects",
"ip unreachables"
]
}
That fixed IPv4 only. The IPv6 lines stayed no ipv6 redirects and no ipv6 unreachables until I also set ipv6_redirects and ipv6_unreachables, which are separate keys. The second run of the fixed file reported changed=0.
overridden: own the whole device
Before running overridden for real I ran it with --check. I'm glad I did:
$ ansible-playbook -i site-inv playbooks/21-l3-state.yml -e st=overridden --check
TASK [Commands Ansible sent (or would send)] ***********************************
ok: [hq-core1] => {
"result.commands": [
"interface Ethernet0/0",
"no ip address 10.66.10.11 255.255.255.0",
"interface Ethernet0/3",
"no ip address 10.66.250.1 255.255.255.0",
"interface Ethernet0/1",
"ipv6 address 2001:db8:66:101::1/64",
"interface Ethernet0/2",
"no ip address 10.66.199.1 255.255.255.0 secondary"
]
}
The first two commands remove the address of the management interface. overridden means "the YAML is the complete L3 config for the device", and the YAML didn't list Ethernet0/0, so the module planned to clear it. The play would have cut its own SSH session partway through, and I'd have been on the console.
A management VRF doesn't protect you here. The module doesn't look at VRFs; it saw an interface with an address that wasn't in the data.
The fix is to put the management interface in the data. With Ethernet0/0 listed (vars/hq-core1-l3-full.yml), the real run removed only the forgotten test subnet and the old secondary:
"result.commands": [
"interface Ethernet0/3",
"no ip address 10.66.250.1 255.255.255.0",
"interface Ethernet0/0",
"no ip redirects",
"no ip unreachables",
"no ipv6 redirects",
"no ipv6 unreachables",
"interface Ethernet0/1",
"ipv6 address 2001:db8:66:101::1/64",
"interface Ethernet0/2",
"no ip address 10.66.199.1 255.255.255.0 secondary"
]
Look at Ethernet0/0 again: the ICMP defaults from the replaced section hit the management port too. Ethernet0/3 kept its description and its shutdown, since ios_l3_interfaces only manages L3 attributes. Descriptions and admin state belong to ios_interfaces, which has the same four states.
deleted: with and without a config list
With an interface named, deleted removes that interface's L3 config and nothing else:
- name: Remove whatever L3 config Ethernet0/3 has
cisco.ios.ios_l3_interfaces:
config:
- name: Ethernet0/3
state: deleted
"result.commands": [
"interface Ethernet0/3",
"no ip address 10.66.250.1 255.255.255.0"
]
Without config, deleted applies to every interface on the device. I only ran this one in check mode, as an ad-hoc command:
$ ansible -i site-inv hq-core1 -m cisco.ios.ios_l3_interfaces -a "state=deleted" --check
[
"interface Ethernet0/0",
"ip redirects",
"ip unreachables",
"ipv6 redirects",
"ipv6 unreachables",
"no ip address 10.66.10.11 255.255.255.0",
"interface Ethernet0/1",
"ipv6 redirects",
"ipv6 unreachables",
"no ip address 10.66.101.1 255.255.255.252",
"interface Ethernet0/2",
"ipv6 redirects",
"ipv6 unreachables",
"no ip address 10.66.199.1 255.255.255.0 secondary",
"no ip address 10.66.102.1 255.255.255.252"
]
Every address on the router, management included. One forgotten config: key turns a cleanup task into an outage, so I never write state: deleted without a config list, and I'd catch it in review the way I'd catch no router ospf in a change ticket.
Note that deleted puts the ICMP settings back to their IOS defaults (ip redirects, ip unreachables), while replaced with no values set turned them off. The two states read a missing key differently.
What each state sent, side by side
| State | Scope | Ethernet0/1 | Ethernet0/2 secondary | Ethernet0/3 test subnet | Ethernet0/0 mgmt IP |
|---|---|---|---|---|---|
| merged | listed interfaces, add only | IPv6 added | kept | kept | kept |
| replaced | listed interfaces, full replace | IPv6 added, ICMP off | removed | kept | kept |
| overridden | whole device | IPv6 added | removed | removed | removed unless listed |
| deleted (one interface) | listed interfaces | untouched | untouched | removed | kept |
| deleted (no config) | whole device | removed | removed | removed | removed |
How I use them now
- Brownfield, first contact:
merged. It can't delete anything. - Interfaces I fully own in the data:
replaced, withredirectsandunreachables(and the IPv6 versions) set explicitly so the module doesn't pick for me. overriddenonly after the data describes every interface on the box, management port included, and only after a--checkrun whosecommandslist I've read.deletedalways with aconfiglist.
Getting that complete data set is the next problem. Typing it by hand for six devices is how you leave out Ethernet0/0. The gathered state reads the running config into the same YAML structure, and the next posts in the series use it as the starting point. For the playbook basics behind all of this, start with the series overview.