Nornir and NAPALM: Vendor-Neutral Network Automation in Python

Ansible is fine until you need real logic — conditionals that span devices, data transformations Jinja chokes on, or concurrency you can actually debug. At that point you are writing a programming language inside YAML, and it hurts. Nornir is the alternative: it is plain Python, so inventory, looping, and error handling are just code, and NAPALM gives it a vendor-neutral way to talk to devices.

This is the stack I reach for when the automation is more than “render a template and push it.”

What Each Piece Does

  • Nornir — an inventory and task-execution framework. It loads your devices, runs tasks against them concurrently with a real thread pool, and gives you structured results. No DSL.
  • NAPALM — a driver layer. get_facts(), get_bgp_neighbors(), load_merge_candidate() behave the same whether the box is IOS-XE, Junos, EOS, or NX-OS. The vendor differences live in the driver, not your code.

Inventory

Nornir’s inventory is YAML, but only the data — the logic stays in Python. hosts.yaml:

leaf1:
hostname: 10.0.0.11
platform: ios
groups: [datacenter]
leaf2:
hostname: 10.0.0.12
platform: junos
groups: [datacenter]

groups.yaml factors out shared attributes:

datacenter:
username: automation
# NAPALM driver comes from each host's `platform` key (ios, junos, ...)

config.yaml wires it together:

inventory:
plugin: SimpleInventory
options:
host_file: "inventory/hosts.yaml"
group_file: "inventory/groups.yaml"
runner:
plugin: threaded
options:
num_workers: 20

Reading State Across the Fleet

A read task is a few lines. Nornir runs it on every host concurrently and collects results:

from nornir import InitNornir
from nornir_napalm.plugins.tasks import napalm_get
from nornir_utils.plugins.functions import print_result
nr = InitNornir(config_file="config.yaml")
def bgp_audit(task):
task.run(task=napalm_get, getters=["bgp_neighbors"])
result = nr.run(task=bgp_audit)
print_result(result)

Because it is Python, filtering and reporting are trivial — find every neighbor that is not Established, across every vendor, in one comprehension:

for host, mres in result.items():
peers = mres[1].result["bgp_neighbors"]["global"]["peers"]
down = [ip for ip, p in peers.items() if not p["is_up"]]
if down:
print(f"{host}: down peers -> {down}")

That same audit in Ansible is a tangle of set_fact, json_query, and loop. Here it is a loop.

Pushing Config with a Diff

NAPALM’s configure flow is the part that makes it safe: load a candidate, show the diff, then commit or discard. Never push blind.

from nornir_napalm.plugins.tasks import napalm_configure
def deploy(task):
cfg = task.host["config_blob"] # rendered elsewhere
r = task.run(
task=napalm_configure,
configuration=cfg,
replace=False, # merge, not full replace
dry_run=True, # compute diff, don't commit
)
if r[0].diff: # task.run returns a MultiResult; index the subtask
print(f"--- {task.host.name} ---\n{r[0].diff}")
nr.run(task=deploy)

dry_run=True is the default mode for review. Flip it to False only after a human (or a pipeline gate) has seen the diffs. On Junos this maps to candidate config + commit; on IOS to a config session — NAPALM hides the difference.

Generating the Config

Nornir renders templates too, so the data-to-config step stays in one place:

from nornir_jinja2.plugins.tasks import template_file
def render(task):
r = task.run(task=template_file,
template=f"{task.host.platform}.j2",
path="templates")
task.host["config_blob"] = r.result

Per-platform templates (ios.j2, junos.j2) let one data model drive multiple vendors — the abstraction that NAPALM provides for state, applied to config generation.

Secrets and Connection Options

Hardcoding credentials in groups.yaml is the first thing reviewers flag. Nornir reads from environment or a secrets backend, and NAPALM’s per-connection extras carry the driver-specific knobs — enable secret, transport, SSH config — without polluting the inventory:

import os
from nornir import InitNornir
nr = InitNornir(config_file="config.yaml")
# Inject credentials at runtime, not in version control
for host in nr.inventory.hosts.values():
host.username = os.environ["NET_USER"]
host.password = os.environ["NET_PASS"]
host.connection_options["napalm"] = host.connection_options.get("napalm")

Driver extras go in groups.yaml under the connection, where they are data, not secrets:

datacenter:
connection_options:
napalm:
extras:
optional_args:
secret: "" # populated from env at runtime
transport: ssh

The split matters in CI: the inventory is committed, the secrets come from the pipeline’s vault, and the same code runs locally against a lab by swapping two environment variables.

Concurrency You Can Debug

The threaded runner runs all hosts in parallel, but results come back as structured objects per host, including exceptions. A failed device does not abort the run — it is marked failed and the rest continue:

result = nr.run(task=deploy)
failed = [h for h, r in result.items() if r.failed]
if failed:
print(f"Failed hosts: {failed}")
# inspect: result[failed[0]].exception

This is the practical advantage over ad-hoc scripts with ThreadPoolExecutor: you get per-host success/failure, the diff, and the exception, without building that plumbing yourself.

Filtering the Inventory

Running every task against the whole fleet is rarely what you want. Nornir’s filter() returns a scoped Nornir object, and filters compose — by group, by platform, by arbitrary attribute. This is the looping logic that lives in Python instead of Ansible’s when: scattered across tasks:

from nornir.core.filter import F
# Only IOS leaves in the datacenter group
targets = nr.filter(F(groups__contains="datacenter") & F(platform="ios"))
targets.run(task=deploy)
# Anything with a data attribute set in inventory
edge = nr.filter(F(role="edge"))

F objects build the predicate without iterating hosts yourself, and because the result is just another Nornir instance, you stack .filter() calls or hand the subset to any task. A staged rollout — group A first, watch, then group B — is two filtered run() calls, not a new playbook.

Commit, Confirm, and Rollback

dry_run=True shows the diff; the commit step is where you decide what happens when the push is wrong. NAPALM exposes the device’s own rollback, and the safe pattern is a two-phase apply: push, verify reachability, then either keep or roll back.

from nornir_napalm.plugins.tasks import napalm_configure, napalm_get
def deploy_verified(task):
task.run(
task=napalm_configure,
configuration=task.host["config_blob"],
replace=False,
dry_run=False, # actually commit
)
# Prove the box is still reachable and BGP is intact
check = task.run(task=napalm_get, getters=["bgp_neighbors"])
peers = check[0].result["bgp_neighbors"]["global"]["peers"]
if any(not p["is_up"] for p in peers.values()):
raise Exception("BGP peer dropped after commit")
result = nr.run(task=deploy_verified)
# Any host whose verification raised gets rolled back to the prior config
for host, mres in result.items():
if mres.failed:
# Nornir owns the connection lifecycle — use it directly, don't wrap in `with`
dev = nr.filter(name=host).inventory.hosts[host].get_connection("napalm", nr.config)
dev.rollback()
print(f"{host}: rolled back")

rollback() reverts to the configuration that was active before the last commit — on Junos that is rollback 1, on IOS-XR a commit replace from the rollback point, on IOS the saved checkpoint. The driver handles the per-vendor mechanics. Pair it with a commit-confirm window where the platform supports it (Junos commit confirmed): if your script dies mid-run before confirming, the device rolls itself back without operator action.

Testing the Automation Before It Touches a Device

The argument for moving off YAML is testability — so test. Nornir tasks are plain functions, which means the filtering and parsing logic runs under pytest against captured NAPALM output, no lab required:

import json
def find_down_peers(bgp_data):
peers = bgp_data["global"]["peers"]
return [ip for ip, p in peers.items() if not p["is_up"]]
def test_find_down_peers():
with open("fixtures/bgp_neighbors_eos.json") as fh:
sample = json.load(fh)
assert find_down_peers(sample) == ["10.0.0.7"]

Capture fixtures once from a real device with napalm_get, then refactor parsing logic freely with a test net under it. For end-to-end runs without hardware, NAPALM ships mocked drivers:

nr = InitNornir(config_file="config.yaml")
# Point a host at the mock driver to exercise the full task path offline
nr.inventory.hosts["leaf1"].platform = "mock"

This is the dividing line between a script and an automation system: one is run by hand and hoped over, the other has tests that fail in CI before the diff ever reaches production.

Where It Fits

Use Ansible whenUse Nornir+NAPALM when
Simple render-and-pushLogic spans devices or needs real data work
Team lives in YAML/playbooksTeam writes Python
One-off config rolloutAudits, reconciliation, custom workflows

It is not Ansible-versus-Nornir as a religious war. Plenty of shops render with Ansible and audit with Nornir. The point is that when your automation grows conditionals and data transforms, dragging it back into YAML is the wrong direction — move it to Python, keep NAPALM for the vendor abstraction, and your automation becomes testable code instead of a templating puzzle.

A Safety Habit

Always wire dry_run=True first and commit the diffs to your change ticket. The number of outages caused by “the template rendered something slightly different than expected” is large, and a diff in front of a human eliminates almost all of them. The automation that pushes config you have not seen is not automation — it is a faster way to break things.