A head-to-head of three network-automation stacks: Ansible+NAPALM (declarative YAML), Nornir (pure Python), and Annet+gnetcli (generators + vendor-aware diff, SSH/Telnet). Clarifies that NAPALM is a library and gnetcli a transport (not frameworks), with a side-by-side table, when-to-choose guidance, and how they compose.
"Which network automation tool should we use?" has no single answer — it depends on your team, your vendors, and how much logic you need. This compares three common stacks head-to-head: Ansible + NAPALM, Nornir, and Annet + gnetcli — their philosophies, where they overlap, and when to pick each.
First, untangle the layers: Ansible, Nornir, and Annet are frameworks. NAPALM is a multi-vendor library (getters + config diff/commit) and gnetcli is a transport (SSH/Telnet as a service). NAPALM and gnetcli plug into frameworks — they aren't alternatives to them.
| Dimension | Ansible + NAPALM | Nornir | Annet + gnetcli |
|---|---|---|---|
| Paradigm | Declarative (YAML) | Imperative (Python) | Generators → structured diff |
| You write | Playbooks + Jinja | Python functions | Python generators (per-vendor) |
| Config model | Templates / resource modules | Whatever you code | Generators + ACL-scoped partial config |
| Diff engine | NAPALM (vendor-dependent) | NAPALM, or your own | Native multi-vendor diff (a core strength) |
| Transport | network_cli/netconf/httpapi; NAPALM drivers | netmiko / NAPALM / scrapli | gnetcli (SSH and Telnet, via a Go daemon) |
| Idempotency | Yes (resource modules); NAPALM replace | You build it (or via NAPALM) | Native — deploy the computed delta |
| Inventory / SoT | Static or dynamic (NetBox) | Inventory plugins (NetBox) | NetBox or file |
| Multi-vendor | Broad (collections + NAPALM) | Via NAPALM/netmiko/scrapli | Strong (breeds + per-vendor generators) |
| Parallelism | Forks (processes) | Threaded runner | gnetcli server handles sessions |
| Testing | molecule, ansible-lint | pytest (plain Python) | pytest + offline fake-device |
| Who / learning curve | Low — ops teams | Medium — Python devs | Higher — platform/NetDevOps teams |
| Ecosystem / maturity | Very large | Solid, growing | Niche (Yandex-origin), focused |
merge/replace, compare_config,
commit/rollback). You can call it standalone, from Ansible (the NAPALM collection), or
from Nornir (nornir_napalm). Its declarative replace + diff is why people add it to
Ansible.| If you… | Lean toward |
|---|---|
| Want low-code, a big module library, mixed server+network ops | Ansible + NAPALM |
| Have complex logic, want unit tests, a Python team, real scale | Nornir |
| Run a large multi-vendor network and want a trustworthy diff + partial config, or need Telnet | Annet + gnetcli |
| Just need cross-vendor facts / a safe config replace | NAPALM (inside any of them) |
One Cisco IOS device, four steps: (1) collect show version, (2)
generate an interface's IP + MTU, (3) see the diff,
(4) apply. Note the asymmetry on step 1:
| Step | Ansible + NAPALM | Nornir | Annet + gnetcli |
|---|---|---|---|
| show version | ✅ ios_command / ios_facts | ✅ netmiko_send_command | ❌ can't — Annet doesn't collect facts |
| generate IP+MTU | template / ios_config / resource module | build lines in Python | a generator (from the SoT) |
| diff | --check --diff | NAPALM dry_run=True | annet diff |
| apply | run without --check | NAPALM dry_run=False | annet deploy |
# 1) show version (ad-hoc)
ansible ios -m cisco.ios.ios_command -a "commands='show version'"
# 2-4) interface IP+MTU — playbook (iface.yml)
- hosts: ios
gather_facts: false
tasks:
- name: Interface IP + MTU
cisco.ios.ios_config:
parents: interface GigabitEthernet0/1
lines:
- ip address 10.0.0.1 255.255.255.0
- mtu 9000
backup: true
# 3) diff (nothing changes): ansible-playbook iface.yml --check --diff
# 4) apply: ansible-playbook iface.yml
from nornir_netmiko import netmiko_send_command
from nornir_napalm.plugins.tasks import napalm_configure
from nornir_utils.plugins.functions import print_result
# 1) show version
print_result(nr.filter(platform="cisco_ios").run(
task=netmiko_send_command, command_string="show version"))
# 2) generate the config (plain Python)
cfg = "interface GigabitEthernet0/1\n ip address 10.0.0.1 255.255.255.0\n mtu 9000\n"
# 3) diff — NAPALM dry-run prints the delta, changes nothing
napalm = nr.filter(platform="ios") # NAPALM uses 'ios' (netmiko uses 'cisco_ios')
print_result(napalm.run(task=napalm_configure, configuration=cfg, dry_run=True))
# 4) apply
napalm.run(task=napalm_configure, configuration=cfg, dry_run=False)
Step 1 is not possible the way it is above: Annet has no facts-collection step. It reads
intent from the SoT (NetBox/file), fetches only the device's running-config to diff against,
and deploys the delta. For arbitrary show output you'd use gnetcli / netmiko / NAPALM alongside it.
# 2) generate — a generator; the ip/mtu come from the SoT, NOT the device
class IfaceIP(PartialGenerator):
def acl_cisco(self, _):
return "interface\n ip address\n mtu"
def run_cisco(self, device):
for iface in device.interfaces:
if iface.ip and iface.mtu:
with self.block(f"interface {iface.name}"):
yield f"ip address {iface.ip} {iface.mask}"
yield f"mtu {iface.mtu}"
# 3) diff — fetch running-config over gnetcli, structured diff vs generated
annet diff cisco-sw1.example.net
# interface GigabitEthernet0/1
# + ip address 10.0.0.1 255.255.255.0
# + mtu 9000
# 4) apply
annet deploy cisco-sw1.example.net
So the honest summary: Ansible and Nornir can pull operational state (show version);
Annet cannot — it is intent/SoT-driven and only ever fetches running-config to compute a diff.
See Annet,
Nornir — Practice, and
Ansible — Cases.
These mix freely: Nornir + NAPALM is a classic pairing; Ansible can shell out to Python; a shop might use Annet for the render-and-diff engine with gnetcli transport while driving surrounding workflow from Nornir. The frameworks are the orchestration; NAPALM/netmiko/scrapli/gnetcli are interchangeable transports and libraries underneath.