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: 20Reading State Across the Fleet
A read task is a few lines. Nornir runs it on every host concurrently and collects results:
from nornir import InitNornirfrom nornir_napalm.plugins.tasks import napalm_getfrom 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.resultPer-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 osfrom nornir import InitNornir
nr = InitNornir(config_file="config.yaml")
# Inject credentials at runtime, not in version controlfor 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: sshThe 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]].exceptionThis 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 grouptargets = nr.filter(F(groups__contains="datacenter") & F(platform="ios"))targets.run(task=deploy)
# Anything with a data attribute set in inventoryedge = 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 configfor 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 offlinenr.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 when | Use Nornir+NAPALM when |
|---|---|
| Simple render-and-push | Logic spans devices or needs real data work |
| Team lives in YAML/playbooks | Team writes Python |
| One-off config rollout | Audits, 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.