Nornir and NAPALM: Cisco Config Management That Is Actually Idempotent

Nornir gives you the inventory and the concurrency netmiko never had. NAPALM asks the router for the diff instead of guessing in Python. Captured on three IOS-XE 17.18.2 nodes, including the dest_file_system fix nobody documents.

Terminal showing napalm_configure returning changed=False and an empty diff on a second identical run

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.1

Note 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: false

platform: 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: false

Five 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 5

Without 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.com

And 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=True

Now 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.

Second identical run with netmiko
Commands sentAll of them, again
Return valueSame text as run 1
Who computes the diffYou do, afterwards
Preview before changeNot available
Second identical run with napalm_configure
Commands sentNone
Return valuechanged=False, diff=''
Who computes the diffThe router, first
Preview before changedry_run=True

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.com

R1 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

Runner hostDebian 13 (trixie) VM, 192.168.99.100 on ens224
Python3.13.5 in a venv
Toolchainnornir 3.5.0, nornir_napalm 0.5.0, nornir-utils 0.2.0, napalm 5.1.0, netmiko 4.7.0
TargetsR1/R2/R3, CML iol-xe, IOS-XE 17.18.2, 192.168.99.1-3
TopologyVM to CML external connector (bridge1) to an ioll2-xe switch to all three routers, one flat /24
Device prepRSA keys, ip ssh version 2, ip scp server enable, archive path unix:archive

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: on iol-xe. NAPALM's IOS driver defaults dest_file_system to flash:, and IOL does not have one. Set dest_file_system: "unix:" in connection_options or nothing that writes config will work. Run show file systems on any unfamiliar platform before you assume.
  • archive is off by default. show archive returning Archive feature not enabled means NAPALM has no diff engine to query, because the diff is show archive config incremental-diffs running on the router.
  • ip scp server enable is not optional. The candidate config goes over SCP. If you cannot enable the SCP server, NAPALM's inline_transfer optional argument is the alternative path.
  • napalm_get sprays kwargs at every getter. getters=['config','interfaces'] with retrieve='running' raises TypeError on get_interfaces(). Split option-taking getters into their own task.
  • Normalised does not mean populated. IOL returns 'model': 'Unknown', empty MAC addresses on loopbacks and last_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, not iosxe. IOS-XE 17.x uses the ios driver. A wrong platform string in groups.yaml surfaces 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, so python3 -m venv fails with an ensurepip is not available error until you install it.
  • Check failed_hosts. Nornir records failures rather than raising them. A run that printed happily can still have skipped devices, and dict(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_location was written once and resolved on all three hosts, while mgmt_loopback stayed per device.
  • NAPALM's diff is generated by the router (show archive config incremental-diffs), not by Python comparing strings. That is why dry_run=True is a preview you can actually trust before a change window.
  • A second identical run returned changed=False with diff_len=0 on all three devices and sent no configuration commands at all. That is real idempotence, not "the commands were harmlessly re-accepted".
  • On CML iol-xe nodes, dest_file_system: "unix:" plus archive and ip scp server enable on 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.

Read next