Almost every Python automation story on a Cisco network starts the same way. You write a netmiko script, it works against one router, you wrap it in a for loop, and suddenly you own a hand-rolled inventory, a hand-rolled thread pool, and a config push that has no idea whether it changed anything. That last part is the one that actually hurts in production. Run the script twice and the output looks identical both times, and the only way to find out whether the second run did anything is to go and diff the running-config yourself.
Nornir solves the inventory and the concurrency. NAPALM solves the "did anything change" problem, and it solves it by asking the router instead of guessing in Python. This article walks the entire loop against real gear: an inventory with group inheritance, a read across three devices at once, a dry-run diff, a commit that reports changed=True, and then the same run again reporting changed=False with an empty diff. If you are still working out how the pieces of a network automation stack fit together, this is the layer where scripts stop being scripts and start being config management.
Everything below is copied from a capture run on 2026-07-24: a Debian 13 VM running Python 3.13.5 in a venv, driving three iol-xe routers on IOS-XE 17.18.2 inside CML. Versions matter here, so they are all listed further down.
What netmiko gives you, and what it does not
netmiko is a session library. It opens an SSH channel, handles the pager and the prompt detection, sends lines, and gives you back a string. That is genuinely the hard part of talking to IOS, and driving a single Cisco device over SSH from Python is still the right starting point for anyone learning this.
What netmiko does not give you is any of the surrounding machinery. There is no concept of a device group, so credentials get pasted into every script. There is no concept of per-device variables, so the loopback address for R2 lives in a dictionary at the top of the file. And critically, there is no concept of state: send_config_set() returns whatever IOS echoed back, which is the same text whether the commands changed something or were no-ops.
That is exactly the gap the declarative tools were built to close. If you have looked at where declarative configuration management tools actually differ, the Nornir plus NAPALM combination sits in the same conceptual box, except the inventory is Python objects and the tasks are Python functions, so you never hit the wall where you need real logic and the DSL will not let you have it.
The inventory is the point
Nornir's SimpleInventory plugin reads two YAML files. hosts.yaml holds what is unique to each device, groups.yaml holds what is shared. Here is the host file used for the capture, verbatim:
R1:
hostname: 192.168.99.1
groups:
- ios_edge
data:
site: pinglabz-lab
role: edge
mgmt_loopback: 10.99.1.1
R2:
hostname: 192.168.99.2
groups:
- ios_edge
data:
site: pinglabz-lab
role: edge
mgmt_loopback: 10.99.2.1
R3:
hostname: 192.168.99.3
groups:
- ios_edge
data:
site: pinglabz-lab
role: edge
mgmt_loopback: 10.99.3.1Note that nothing in there is a credential and nothing in there is a platform. Those live once, on the group:
ios_edge:
platform: ios
username: admin
password: Cisco123
data:
snmp_location: PingLabz-Lab-Rack1
connection_options:
napalm:
extras:
optional_args:
dest_file_system: "unix:"
netmiko:
extras:
fast_cli: falseplatform: ios is worth pausing on. These routers run IOS-XE 17.18.2, but the NAPALM driver name is still ios. There is no iosxe driver, and setting the platform to something NAPALM does not recognise is one of the most common ways to get a connection failure that looks like an auth problem but is not.
The connection_options block is where the whole thing becomes usable on lab gear, and it gets its own section below. First, the payoff of having an inventory at all. Loading it and printing the resolved attributes per host:
! Command: python -> InitNornir(config_file='nornir/config.yaml')
! Command: python -> nr.inventory.hosts
{'R1': Host: R1, 'R2': Host: R2, 'R3': Host: R3}
! Command: python -> per-host resolved attributes (group inheritance in action)
HOST hostname platform username mgmt_loopback snmp_location
R1 192.168.99.1 ios admin 10.99.1.1 PingLabz-Lab-Rack1
R2 192.168.99.2 ios admin 10.99.2.1 PingLabz-Lab-Rack1
R3 192.168.99.3 ios admin 10.99.3.1 PingLabz-Lab-Rack1
! Command: python -> nr.filter(role='edge').inventory.hosts.keys()
['R1', 'R2', 'R3']snmp_location is written once, on the group, and resolves on all three hosts. mgmt_loopback is per host. That single table is the argument for keeping an inventory instead of a list of IP addresses, and nr.filter() is how a forty-device inventory becomes a three-device run without editing a file (change the filter, not the source of truth).
The third file, config.yaml, wires it together and sets the runner:
inventory:
plugin: SimpleInventory
options:
host_file: /home/j/plzcap/nornir/hosts.yaml
group_file: /home/j/plzcap/nornir/groups.yaml
runner:
plugin: threaded
options:
num_workers: 5
logging:
enabled: falseFive workers, threaded. You did not write a thread pool, and you will not write one.
The one line that makes NAPALM work on iol-xe
This is the thing that stops most people dead about five minutes into their first lab, so it gets its own heading.
NAPALM's IOS driver does not type your config into a terminal. It writes the candidate configuration to a file on the device, transfers it over SCP, and then asks IOS to diff and merge it. The destination it writes to defaults to flash:, which is correct on almost every physical Catalyst or ISR you will ever touch.
iol-xe nodes in CML have no flash: filesystem. show file systems on IOL lists nvram:, unix: (the 2 GB default), disk0:, disk1: and system:, and that is the complete list. Every load_merge_candidate() call dies before it ever produces a diff, and the traceback points at file operations rather than at anything you did wrong. The fix is four lines of inventory:
connection_options:
napalm:
extras:
optional_args:
dest_file_system: "unix:"Two more things have to be true on the device itself, and neither is on by default:
ip scp server enable ! napalm ships the candidate config over SCP
!
archive ! napalm's merge diff is computed BY THE ROUTER
path unix:archive ! 'flash:archive' on a real box
maximum 5Without ip scp server enable, the file transfer fails outright. Without archive, show archive answers Archive feature not enabled and there is no diff engine on the box for NAPALM to interrogate. Check all three before you blame the library: show ip ssh, show archive, and show file systems to find the writable disk.
Reading three devices at once
With the inventory loaded, a multi-device read is one line. napalm_get takes a list of getters and returns the same normalised structure for every platform:
from nornir import InitNornir
from nornir_utils.plugins.functions import print_result
from nornir_napalm.plugins.tasks import napalm_get, napalm_configure
nr = InitNornir(config_file="nornir/config.yaml")
r = nr.run(task=napalm_get, getters=["facts", "interfaces"])
print_result(r)Trimmed to R1 (R2 and R3 returned the identical shape):
napalm_get**********************************************************************
* R1 ** changed : False ********************************************************
vvvv napalm_get ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
{ 'facts': { 'fqdn': 'R1.pinglabz.lab',
'hostname': 'R1',
'interface_list': [ 'Ethernet0/0',
'Ethernet0/1',
'Ethernet0/2',
'Ethernet0/3',
'Loopback0',
'Loopback14'],
'model': 'Unknown',
'os_version': 'Linux Software (X86_64BI_LINUX-ADVENTERPRISEK9-M), '
'Version 17.18.2, RELEASE SOFTWARE (fc3)',
'serial_number': '2040027',
'uptime': 480.0,
'vendor': 'Cisco'},
'interfaces': { 'Ethernet0/0': { 'description': '',
'is_enabled': True,
'is_up': True,
'last_flapped': -1.0,
'mac_address': 'AA:BB:CC:00:DB:00',
'mtu': 1500,
'speed': 10.0},
'Loopback14': { 'description': 'PingLabz RES-0014 pushed by '
'netmiko',
'is_enabled': True,
'is_up': True,
'last_flapped': -1.0,
'mac_address': '',
'mtu': 1514,
'speed': 8000.0}}}
^^^^ END napalm_get ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^Two details in there are worth reading closely. Loopback14 carries a description left behind by an earlier netmiko run against the same three routers, which is a small live demonstration of why you want a tool that can tell you what is on the box rather than what you think you put there. And 'model': 'Unknown' is honest: IOL does not report a chassis model, so NAPALM returns the key with nothing useful in it rather than inventing a value. Same for 'mac_address': '' and 'last_flapped': -1.0 on loopbacks. Normalised does not mean populated.
Collapsing the same result set down to what you would actually put in a report:
HOST vendor model os_version fqdn uptime_s
R1 Cisco Unknown Linux Soft R1.pinglabz.lab 480.0
R2 Cisco Unknown Linux Soft R2.pinglabz.lab 480.0
R3 Cisco Unknown Linux Soft R3.pinglabz.lab 480.0
R1 up=['Ethernet0/0', 'Loopback0', 'Loopback14']
R2 up=['Ethernet0/0', 'Loopback0', 'Loopback14']
R3 up=['Ethernet0/0', 'Loopback0', 'Loopback14']
! failed hosts: {} (empty dict = every device answered)failed_hosts being an empty dict is the check you want in every script you write. Nornir does not raise on a device failure, it records it and carries on with the rest, so a run that "worked" can quietly have skipped half your fleet if you never look.
The diff comes from the router, not from Python
This is the architectural difference between NAPALM and a session library, and it is worth being precise about it.
The config is rendered per host from inventory data. A task function builds it, pulling site and mgmt_loopback from the host and snmp_location from the group:
def build(task):
h = task.host
return "\n".join([
"interface Loopback1",
" description NORNIR-MANAGED %s" % h["site"],
" ip address %s 255.255.255.0" % h["mgmt_loopback"],
" ip ospf 1 area 0",
"!",
"snmp-server location %s" % h["snmp_location"],
"snmp-server contact netops@pinglabz.com",
])
def cfg(task):
return napalm_configure(task, configuration=build(task),
replace=False, dry_run=True)
print_result(nr.run(task=cfg, name="napalm_configure DRY RUN"))Which renders, for R1:
! ---- R1 ----
interface Loopback1
description NORNIR-MANAGED pinglabz-lab
ip address 10.99.1.1 255.255.255.0
ip ospf 1 area 0
!
snmp-server location PingLabz-Lab-Rack1
snmp-server contact netops@pinglabz.comAnd the dry run returns this:
napalm_configure DRY RUN********************************************************
* R1 ** changed : True *********************************************************
vvvv napalm_configure DRY RUN ** changed : True vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
+interface Loopback1
+ description NORNIR-MANAGED pinglabz-lab
+ ip address 10.99.1.1 255.255.255.0
+ ip ospf 1 area 0
+snmp-server location PingLabz-Lab-Rack1
+snmp-server contact netops@pinglabz.com
^^^^ END napalm_configure DRY RUN ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* R2 ** changed : True *********************************************************
vvvv napalm_configure DRY RUN ** changed : True vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
+interface Loopback1
+ description NORNIR-MANAGED pinglabz-lab
+ ip address 10.99.2.1 255.255.255.0
+ ip ospf 1 area 0
+snmp-server location PingLabz-Lab-Rack1
+snmp-server contact netops@pinglabz.com
^^^^ END napalm_configure DRY RUN ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^Those + lines were not produced by Python comparing two strings. NAPALM copied the candidate to unix:candidate_config over SCP and then ran show archive config incremental-diffs on the router. The Contextual Configuration Diff Utility in IOS-XE, the same engine behind configure replace and config rollback, is what decided which lines are new. For a full replace instead of a merge the driver asks for show archive config differences instead.
That distinction is why the output is trustworthy. A Python-side diff has to model IOS parser behaviour: submode context, command ordering, the fact that ip ospf 1 area 0 under an interface is not the same statement as anywhere else, defaults that get suppressed from show run. The device already knows all of that, because it is the thing that parses the config. With dry_run=True, NAPALM discards the candidate instead of committing it, so you get the router's own answer to "what would this change" without changing anything.
Commit, then run it again
The commit is the same call with dry_run=False:
napalm_configure COMMIT*********************************************************
* R1 ** changed : True *********************************************************
vvvv napalm_configure COMMIT ** changed : True vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
+interface Loopback1
+ description NORNIR-MANAGED pinglabz-lab
+ ip address 10.99.1.1 255.255.255.0
+ ip ospf 1 area 0
+snmp-server location PingLabz-Lab-Rack1
+snmp-server contact netops@pinglabz.com
^^^^ END napalm_configure COMMIT ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
! Command: python -> result[host][0].changed
R1 changed=True
R2 changed=True
R3 changed=TrueNow the part this whole article exists for. Same script, same inventory, same rendered config, run a second time immediately afterwards:
napalm_configure RE-RUN (identical input)***************************************
* R1 ** changed : False ********************************************************
vvvv napalm_configure RE-RUN (identical input) ** changed : False vvvvvvvvvvvvvv INFO
^^^^ END napalm_configure RE-RUN (identical input) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* R2 ** changed : False ********************************************************
vvvv napalm_configure RE-RUN (identical input) ** changed : False vvvvvvvvvvvvvv INFO
^^^^ END napalm_configure RE-RUN (identical input) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* R3 ** changed : False ********************************************************
vvvv napalm_configure RE-RUN (identical input) ** changed : False vvvvvvvvvvvvvv INFO
^^^^ END napalm_configure RE-RUN (identical input) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
! Command: python -> (result[host][0].changed, len(result[host][0].diff))
R1 changed=False diff_len=0 diff=''
R2 changed=False diff_len=0 diff=''
R3 changed=False diff_len=0 diff=''
! First run: changed={'R1': True, 'R2': True, 'R3': True}Read what is between the vvvv and ^^^^ markers on the second run: nothing. No diff, because the router had nothing to report. And because the diff came back empty, NAPALM skipped the commit entirely.
That is the distinction people gloss over when they say a script is idempotent. Re-sending snmp-server location PingLabz-Lab-Rack1 to a box that already has it is harmless, and a lot of automation is called idempotent on exactly that basis. It is not the same claim. Here, no configuration commands were sent at all, and the tool reported that fact in a boolean you can branch on.
Verify on the box, not in the return value
A tool reporting success is not the same as the network being right. Pull the running config back and look:
! ---- R1 ----
! Command: napalm_get(getters=['config'], retrieve='running') -> Loopback1 stanza
interface Loopback1
description NORNIR-MANAGED pinglabz-lab
ip address 10.99.1.1 255.255.255.0
ip ospf 1 area 0
! Command: python -> [l for l in running if l.startswith('snmp-server')]
snmp-server location PingLabz-Lab-Rack1
snmp-server contact netops@pinglabz.com
! Command: napalm_get(getters=['interfaces']) -> ['Loopback1']
{'is_enabled': True, 'is_up': True, 'description': 'NORNIR-MANAGED pinglabz-lab', 'mac_address': '', 'last_flapped': -1.0, 'mtu': 1514, 'speed': 8000.0}
! ---- R3 ----
! Command: napalm_get(getters=['config'], retrieve='running') -> Loopback1 stanza
interface Loopback1
description NORNIR-MANAGED pinglabz-lab
ip address 10.99.3.1 255.255.255.0
ip ospf 1 area 0
! Command: python -> [l for l in running if l.startswith('snmp-server')]
snmp-server location PingLabz-Lab-Rack1
snmp-server contact netops@pinglabz.comR1 got 10.99.1.1, R3 got 10.99.3.1, each rendered from that host's own inventory data, and both got the same snmp-server lines because those came from the group. The interface is is_up: True with the description NAPALM was told to apply. The devices are running exactly what the inventory says they should be running, which is the entire claim configuration management makes.
One trap in that snippet: it is two separate task runs, not one. napalm_get forwards every extra keyword argument to every getter you asked for, so combining a getter that takes options with one that does not blows up:
! NOTE: napalm_get passes every extra kwarg to EVERY getter, so
! napalm_get(getters=['config','interfaces'], retrieve='running')
! fails with: TypeError: IOSDriver.get_interfaces() got an unexpected keyword
! argument 'retrieve'. Getters that take options must be run in their own task.Verification like this is where the third leg of the stack picks up. Pulling config back and eyeballing it is fine for one loopback; when you want structured pass or fail assertions across a fleet, turning device state into an actual test suite is the tool for that job. And if you want the short version of which of the three libraries to reach for and when, there is a straight comparison of the three Python options.
What this was captured on
The flat layer 2 design is deliberate. The VM and all three routers sit in one broadcast domain, so there is no routing and no static routes between the automation host and the gear it manages, which removes an entire class of "is it my script or is it my network" ambiguity from the capture.
Gotchas
- No
flash:oniol-xe. NAPALM's IOS driver defaultsdest_file_systemtoflash:, and IOL does not have one. Setdest_file_system: "unix:"inconnection_optionsor nothing that writes config will work. Runshow file systemson any unfamiliar platform before you assume. archiveis off by default.show archivereturningArchive feature not enabledmeans NAPALM has no diff engine to query, because the diff isshow archive config incremental-diffsrunning on the router.ip scp server enableis not optional. The candidate config goes over SCP. If you cannot enable the SCP server, NAPALM'sinline_transferoptional argument is the alternative path.napalm_getsprays kwargs at every getter.getters=['config','interfaces']withretrieve='running'raisesTypeErroronget_interfaces(). Split option-taking getters into their own task.- Normalised does not mean populated. IOL returns
'model': 'Unknown', empty MAC addresses on loopbacks andlast_flapped: -1.0. NAPALM gives you a consistent key set across vendors, not a guarantee that every key has a value. - The driver is
ios, notiosxe. IOS-XE 17.x uses theiosdriver. A wrong platform string ingroups.yamlsurfaces as a connection error that reads like a credentials problem. - Python 3.13 is a non-event, but Debian 13 is. nornir, napalm and netmiko all installed cleanly from wheels on 3.13.5. The only friction was that Debian 13 does not ship
python3.13-venv, sopython3 -m venvfails with anensurepip is not availableerror until you install it. - Check
failed_hosts. Nornir records failures rather than raising them. A run that printed happily can still have skipped devices, anddict(result.failed_hosts)being{}is the only thing that proves otherwise.
Key takeaways
- The inventory, not the SSH session, is what turns a script into config management. Group inheritance meant
snmp_locationwas written once and resolved on all three hosts, whilemgmt_loopbackstayed per device. - NAPALM's diff is generated by the router (
show archive config incremental-diffs), not by Python comparing strings. That is whydry_run=Trueis a preview you can actually trust before a change window. - A second identical run returned
changed=Falsewithdiff_len=0on all three devices and sent no configuration commands at all. That is real idempotence, not "the commands were harmlessly re-accepted". - On CML
iol-xenodes,dest_file_system: "unix:"plusarchiveandip scp server enableon the device are the three prerequisites. Miss any one and NAPALM fails before it reaches the interesting part. - Always verify on the box. The running config on R1, R2 and R3 matched the intended template with each device's own address, which is the only proof that matters.
Nornir and NAPALM are one layer of a stack, not the whole thing. netmiko still underpins the session handling, on-box tooling has its place when you want logic running on the switch itself rather than on a management server, and a test framework covers the validation side. The full automation cluster, in reading order, walks through where each of them belongs.