Nornir for Network Engineers: Running Automation Across an Inventory at Scale
When the script outgrows itself
There is a recognisable moment in every network automation project. The script started life as a single-file thing that ran a show command across a small JSON inventory. It grew. Now it runs different commands against different roles. It collates results into CSV. It sometimes pushes config. It has its own credential handling, its own concurrency, its own error retry logic, and its own filter logic for “just the core routers in Manchester”. You are building a framework, badly, by accident.
Nornir is the framework you should have started with. It is a Python automation framework specifically designed for the inventory-driven, multi-device pattern that everyone ends up reinventing. It does not replace Netmiko, NAPALM, Scrapli, or pyATS — it uses them as connection plugins. What it adds is the inventory model, the task abstraction, the parallelism, the filtering, and the structured result handling.
This post is for engineers who already have a working cmdrunner.py-style script and have started feeling the seams. It walks through the Nornir model, ports a real script into it, and shows the patterns that hold up as the inventory grows.
The shape of Nornir
Nornir has three things you have to understand:
-
Inventory. A collection of hosts (and groups of hosts) with attributes, loaded from a file, a directory of YAML, an Ansible inventory, NetBox, or anything else you write a plugin for. Each host carries credentials, platform, role, location — whatever metadata you want.
-
Tasks. Plain Python functions that take a
Taskobject and do work for a single host. Tasks can call other tasks, return results, and chain together. -
Runner. The thing that runs a task across all matching hosts in parallel and gives you back a structured result. Filtering happens here — “run this task only on hosts where role is core_router”.
Once you have those three concepts, the rest is plumbing.
Installation and minimum viable setup
pip install nornir nornir_utils nornir_netmiko nornir_napalm
Nornir’s core does not include the connection plugins; you install the ones you want. nornir_netmiko adds Netmiko-based tasks; nornir_napalm adds NAPALM-based tasks; nornir_utils adds a print-result helper that is essential for development.
A minimal config.yaml:
inventory:
plugin: SimpleInventory
options:
host_file: "inventory/hosts.yaml"
group_file: "inventory/groups.yaml"
defaults_file: "inventory/defaults.yaml"
runner:
plugin: threaded
options:
num_workers: 20
A minimal inventory/hosts.yaml:
csr1:
hostname: 10.0.0.1
groups: [core, manchester]
data:
role: core_router
csr2:
hostname: 10.0.0.2
groups: [core, manchester]
data:
role: core_router
sw1:
hostname: 10.0.1.1
groups: [access, manchester]
data:
role: access_switch
A minimal inventory/groups.yaml:
core:
platform: ios
connection_options:
netmiko:
extras:
device_type: cisco_ios
napalm:
extras:
optional_args:
secret: "redacted"
access:
platform: ios
connection_options:
netmiko:
extras:
device_type: cisco_ios
manchester:
data:
site: manchester
timezone: Europe/London
A minimal inventory/defaults.yaml:
username: alice
# password loaded from environment, never committed
The hosts inherit from the groups they belong to. csr1 is in core and manchester, so it picks up the platform and connection options from core and the site/timezone from manchester.
A first task
The “hello world” of Nornir is running a show command across the inventory. Using nornir_netmiko:
import os
from nornir import InitNornir
from nornir_netmiko.tasks import netmiko_send_command
from nornir_utils.plugins.functions import print_result
nr = InitNornir(config_file="config.yaml")
nr.inventory.defaults.password = os.environ["NETOPS_PASSWORD"]
result = nr.run(
task=netmiko_send_command,
command_string="show ip interface brief",
use_textfsm=True,
)
print_result(result)
That is the entire script. Twenty lines, including imports and credential loading, and it does what cmdrunner.py did, with parallelism, structured inventory, and structured output, across however many devices are in the inventory file.
Filtering
The point of an inventory model is that you can filter. Suppose you want to run a task only on the core routers in Manchester:
core_manchester = nr.filter(role="core_router", site="manchester")
result = core_manchester.run(task=netmiko_send_command, command_string="show ip route")
Nornir’s filter syntax matches arbitrary inventory data. You can filter on platform, group membership, or any custom attribute. For more complex conditions there is the F() filter object:
from nornir.core.filter import F
# Hosts in the core group AND running ios-xe
target = nr.filter(F(groups__contains="core") & F(platform="iosxe"))
# Hosts NOT in the lab group
target = nr.filter(~F(groups__contains="lab"))
# Hosts in either Manchester or London
target = nr.filter(F(site="manchester") | F(site="london"))
This is the filtering layer that homegrown scripts always end up reinventing badly. Use it.
Custom tasks
A task is just a function. Nornir provides one with the Task argument and expects you to return a Result. Inside the task you can call other tasks, including the bundled ones from nornir_netmiko or nornir_napalm.
A real example: audit all interfaces for CRC errors and produce a row per interface with errors.
from nornir.core.task import Task, Result
from nornir_netmiko.tasks import netmiko_send_command
def audit_crc(task: Task) -> Result:
output = task.run(
task=netmiko_send_command,
command_string="show interface counters errors",
use_textfsm=True,
)
rows = output.result # list of dicts from TextFSM
bad = [
{"host": task.host.name, "intf": r["port"], "crc": int(r.get("crc", 0))}
for r in rows
if int(r.get("crc", 0)) > 0
]
return Result(host=task.host, result=bad, failed=False)
result = nr.run(task=audit_crc)
# Aggregate across all hosts
all_bad = []
for host, host_result in result.items():
if host_result.failed:
print(f"[FAIL] {host}: {host_result.exception}")
continue
all_bad.extend(host_result[0].result)
# Now write to CSV, post to Slack, whatever
The pattern: a task wraps the work for one host, returns a structured Result, and the runner gives you back a dict of results keyed by hostname. Failures are surfaced cleanly without taking down the run.
Mixing connection plugins in one task
Nornir lets you mix Netmiko, NAPALM, and other connection types in a single task. This is where the hybrid pattern from the previous post becomes painless.
from nornir_netmiko.tasks import netmiko_send_command
from nornir_napalm.plugins.tasks import napalm_get
def device_audit(task: Task) -> Result:
facts = task.run(task=napalm_get, getters=["facts", "interfaces"])
raw_acls = task.run(task=netmiko_send_command, command_string="show access-lists")
return Result(
host=task.host,
result={
"facts": facts.result,
"interfaces": facts.result["interfaces"],
"acls_raw": raw_acls.result,
},
)
NAPALM’s getters give you the structured facts. Netmiko handles the vendor-specific show command. The same authentication, the same inventory entry, two different connection plugins under the hood.
Config changes safely
Pushing config across an inventory is where Nornir’s structured failure handling earns its keep. The pattern is: dry-run mode prints the diff, apply mode commits.
from nornir_napalm.plugins.tasks import napalm_configure
def apply_ntp(task: Task, dry_run: bool = True) -> Result:
config = """
ntp server 10.0.0.10
ntp server 10.0.0.11
"""
return task.run(
task=napalm_configure,
configuration=config,
dry_run=dry_run,
)
# Dry run first
preview = nr.filter(role="core_router").run(task=apply_ntp, dry_run=True)
print_result(preview)
# Confirm the diffs are what you want, then apply
result = nr.filter(role="core_router").run(task=apply_ntp, dry_run=False)
print_result(result)
NAPALM’s dry_run flag means the candidate config is loaded, compared, and discarded without committing. Nornir runs that across the filtered inventory in parallel and gives you a per-device diff. You read the diffs. You apply.
If a device fails partway through, the rest of the inventory still runs. Failures are scoped per-device and surfaced in the result dict. Compare to a Bash loop with ssh ... && configure ... && commit, where one failure stops the lot.
Inventory plugins
The SimpleInventory plugin is fine for small static inventories. Real production estates have authoritative sources of truth — a CMDB, NetBox, Ansible, Infoblox — and the inventory should come from there.
The most common production setup uses NetBox:
pip install nornir_netbox
inventory:
plugin: NetBoxInventory2
options:
nb_url: "https://netbox.example.com"
nb_token: "redacted"
filter_parameters:
status: active
Now Nornir loads its inventory directly from NetBox, with all the device metadata, site information, and platform attributes that are already in there. Adding a new device to NetBox automatically makes it available to your automation. No more keeping a separate routers.json in sync with the source of truth.
Other inventory plugins exist for Ansible, for YAML files in a directory, and for arbitrary custom sources. Writing your own is also reasonable — the interface is small.
Result handling and result aggregation
print_result is for development. Production code does something with the result.
The result returned by nr.run() is a MultiResult — a dict-like object keyed by hostname, where each value is a list of Result objects (one per task call within the run). Iterating it cleanly:
result = nr.run(task=audit_crc)
aggregate = []
errors = []
for host_name, host_result in result.items():
if host_result.failed:
errors.append({
"host": host_name,
"error": str(host_result.exception),
})
continue
# host_result is a list of Results for the tasks in this run
final = host_result[0]
aggregate.extend(final.result)
# write aggregate to CSV/SQLite/JSON
# write errors to a separate report
A small helper I usually write in any Nornir project:
def collect_aggregate(result):
rows, errors = [], []
for host_name, host_result in result.items():
if host_result.failed:
errors.append({"host": host_name, "error": str(host_result.exception)})
else:
rows.extend(host_result[0].result or [])
return rows, errors
This turns a Nornir result into two flat lists you can write straight to CSV.
Writing your own connection plugin
You will not need to do this often, but it is worth knowing. Nornir’s connection plugins are small classes that implement open(), close(), and expose a connection object. If you have a vendor that none of the existing plugins cover, you can write one in a few dozen lines and have your custom platform integrated into the same inventory and parallelism model as everything else.
Most engineers will never write one. It is good to know it is possible.
Performance
Threaded runners with twenty workers are the right default for most network estates. Threads work because Netmiko and NAPALM are both I/O-bound. The GIL is not the bottleneck.
For very large inventories — hundreds or thousands of devices — there are a couple of patterns worth knowing:
- Increase worker count carefully. A burst of 200 simultaneous SSH authentications will overwhelm a single TACACS server. Talk to whoever runs your AAA before you crank this up.
- Batch inventory. Instead of running a task across all 1,000 devices in one go, filter into chunks of 100 and run sequentially. Easier to reason about, easier to recover from partial failures.
- Move to async. Nornir 3 supports async runners. For huge inventories where the SSH handshake is the bottleneck, async can be substantially faster than threads.
For most production networks — a few hundred devices — the threaded runner with 20–30 workers is the right answer.
Migrating from a hand-rolled script
The migration path from a cmdrunner.py-style script to Nornir is incremental. The work falls into three phases:
-
Convert the inventory. Take the JSON inventory and turn it into Nornir’s
hosts.yamlandgroups.yaml. This is mechanical. At this point you have a Nornir inventory but the script still uses Netmiko directly. -
Wrap the existing logic in a Nornir task. Take the loop body and turn it into a function that accepts a
Taskobject. The bits that werefor dev in devices:go away — Nornir handles that. The bits that calledConnectHandlerdirectly become calls tonetmiko_send_command. -
Refactor the orchestration. What used to be ad-hoc concurrency, error handling, and result aggregation becomes Nornir’s runner, the per-host result objects, and your aggregation helper.
Each phase is a few hours of work for a script of moderate size. None of them require throwing away what you have. The Netmiko knowledge transfers directly. The TextFSM templates transfer directly. What gets replaced is the orchestration scaffolding, which is the part that was costing you maintenance time anyway.
When not to bother
A small caveat. Nornir is overkill for a one-off script that runs once a quarter against five devices. If your script is small, simple, and stable, leave it alone. The point at which Nornir earns its complexity is when you have:
- An inventory big enough that filtering is useful (above ~20 devices).
- Multiple distinct tasks reusing the same inventory and credentials.
- A team rather than a single engineer maintaining the code.
- A need to run things from CI or a scheduler reliably.
Below that threshold, Netmiko plus a JSON file plus concurrent.futures is fine. Above it, Nornir saves you reimplementing the same scaffolding for every project.
What this leaves you with
A Nornir project, set up well, looks like this:
project/
├── config.yaml
├── inventory/
│ ├── hosts.yaml
│ ├── groups.yaml
│ └── defaults.yaml
├── tasks/
│ ├── audits.py
│ ├── changes.py
│ └── reports.py
├── templates/ # TextFSM/TTP/Jinja2
└── runners/
├── nightly_audit.py
└── ad_hoc.py
Inventory in one place, tasks as reusable functions, runners as small entry points that orchestrate. Adding a new device is editing YAML; adding a new audit is writing a function; adding a new report is composing existing tasks. The thing that used to be cmdrunner.py is now a small, well-organised Python project that scales with the network rather than against it.
The next post in this series wraps the toolkit into a polished CLI tool — argparse to Click, table output with Rich, secret loading, and the kind of packaging that lets your colleagues pip install netops-tools and use what you have built without copying scripts around.