Python for Network Engineers — Part 10: NAPALM — Vendor-Agnostic Network Automation
By Part 9, you had two ways to talk to devices: Netmiko over SSH (returns strings, needs parsing) and REST APIs (returns JSON, vendor-specific). Both work, but both require you to know which vendor you’re talking to and adjust your code accordingly. Every if device_type == "arista_eos" branch is a place where adding a new vendor means editing your code.
NAPALM (Network Automation and Programmability Abstraction Layer with Multivendor support) solves this. It wraps SSH, eAPI, NETCONF, and REST into a uniform Python interface. get_interfaces() on an Arista device and get_interfaces() on a Cisco IOS-XE device return the same dict structure. The same script collects inventory across a multi-vendor fleet without a single vendor-specific branch.
Installing NAPALM
uv pip install napalm
NAPALM supports multiple drivers. The core package includes:
| Driver | Platform | Transport |
|---|---|---|
eos | Arista EOS | eAPI (HTTP) |
ios | Cisco IOS / IOS-XE | SSH via Netmiko |
iosxr | Cisco IOS-XR | SSH via Netmiko |
nxos_ssh | Cisco NX-OS | SSH via Netmiko |
junos | Juniper JunOS | NETCONF |
Community-maintained drivers exist for additional platforms including FortiOS and PAN-OS — install them as separate packages if needed.
Connecting to a Device
import napalm
driver = napalm.get_network_driver("eos")
device = driver(
hostname="192.168.100.10",
username="admin",
password="netauto",
optional_args={"port": 443}, # driver-specific options
)
device.open()
# ... do work ...
device.close()
Always use a context manager in real code — it ensures close() is called even if an exception occurs:
driver = napalm.get_network_driver("eos")
with driver(hostname="192.168.100.10", username="admin", password="netauto") as device:
facts = device.get_facts()
print(facts)
Optional Args
optional_args is a dict of driver-specific parameters. Common ones:
| Driver | Useful optional_args |
|---|---|
eos | {"port": 443} (HTTPS port) |
ios | {"port": 22}, {"secret": "enable_password"} |
junos | {"port": 830} (NETCONF port) |
Check the NAPALM documentation for your driver’s full list of optional args.
Getters
Getters are NAPALM’s read-only methods. Every driver implements the same set, returning the same dict structure regardless of vendor. This is the core value proposition.
get_facts()
Basic device information:
facts = device.get_facts()
print(facts)
{
"uptime": 86400,
"vendor": "Arista",
"os_version": "4.32.0F",
"serial_number": "ABC1234567",
"model": "cEOSLab",
"hostname": "spine-01",
"fqdn": "spine-01.lab.local",
"interface_list": ["Ethernet1", "Ethernet2", "Loopback0", "Management0"],
}
The same call on a Cisco IOS-XE device returns:
{
"uptime": 259200,
"vendor": "Cisco",
"os_version": "17.09.01a",
"serial_number": "FTX1234ABCD",
"model": "C8300-1N1S-4T2X",
"hostname": "core-rtr-01",
"fqdn": "core-rtr-01.corp.local",
"interface_list": ["GigabitEthernet0/0/0", "GigabitEthernet0/0/1", "Loopback0"],
}
Same keys. Different values. Your code doesn’t change.
get_interfaces()
Detailed interface state:
interfaces = device.get_interfaces()
for name, data in interfaces.items():
if data["is_up"]:
print(f"{name:<25} up/{data['speed']}Mbps {data['description']}")
{
"Ethernet1": {
"is_up": True,
"is_enabled": True,
"description": "Link to leaf-01 Ethernet1",
"last_flapped": 3600.0,
"speed": 1000,
"mtu": 1500,
"mac_address": "AA:BB:CC:DD:EE:01",
},
"Ethernet2": {
"is_up": True,
"is_enabled": True,
"description": "Link to leaf-02 Ethernet1",
"last_flapped": 3601.0,
"speed": 1000,
"mtu": 1500,
"mac_address": "AA:BB:CC:DD:EE:02",
},
...
}
get_interfaces_ip()
IP addresses per interface:
ip_info = device.get_interfaces_ip()
for intf, data in ip_info.items():
for family, addrs in data.items(): # "ipv4" or "ipv6"
for prefix, prefix_data in addrs.items():
print(f"{intf}: {prefix}/{prefix_data['prefix_length']}")
get_bgp_neighbors()
BGP neighbour state across all VRFs:
bgp = device.get_bgp_neighbors()
for vrf, vrf_data in bgp.items():
for peer_ip, peer_data in vrf_data["peers"].items():
state = "UP" if peer_data["is_up"] else "DOWN"
print(f"VRF {vrf}: {peer_ip} AS{peer_data['remote_as']} — {state}")
get_lldp_neighbors_detail()
LLDP neighbour discovery — particularly useful for topology mapping:
lldp = device.get_lldp_neighbors_detail()
for local_port, neighbours in lldp.items():
for n in neighbours:
print(f"{local_port} → {n['remote_system_name']} {n['remote_port']}")
get_route_to()
Route lookup for a specific destination:
routes = device.get_route_to("10.0.0.0/8")
for prefix, route_list in routes.items():
for route in route_list:
print(f"{prefix} via {route['next_hop']} [{route['protocol']}]")
get_arp_table() / get_mac_address_table()
ARP and MAC tables — same structure across vendors:
arp = device.get_arp_table()
for entry in arp:
print(f"{entry['ip']:<16} {entry['mac']}")
macs = device.get_mac_address_table()
for entry in macs:
print(f"VLAN {entry['vlan']:<5} {entry['mac']:<20} {entry['interface']}")
Full Getter Reference
| Getter | Returns |
|---|---|
get_facts() | Basic device info |
get_interfaces() | Interface state |
get_interfaces_ip() | IP addresses |
get_interfaces_counters() | Traffic counters |
get_bgp_neighbors() | BGP peer state |
get_bgp_neighbors_detail() | Extended BGP data |
get_lldp_neighbors() | LLDP neighbour summary |
get_lldp_neighbors_detail() | Full LLDP detail |
get_arp_table() | ARP table |
get_mac_address_table() | MAC address table |
get_route_to(destination) | Route lookup |
get_ntp_servers() | Configured NTP servers |
get_ntp_stats() | NTP sync state |
get_snmp_information() | SNMP config |
get_users() | Local user accounts |
get_vlans() | VLAN database |
get_environment() | Temperature, fans, PSU state |
get_config() | Running/startup/candidate config |
Not every driver implements every getter — call device.get_facts() first and check whether you get an accurate result before building a workflow around a specific getter. NAPALM raises NotImplementedError for getters a driver doesn’t support.
Configuration Management
NAPALM’s configuration model is different from Netmiko’s send_config_set(). Rather than sending individual commands, NAPALM treats configuration as a full replace or merge operation against a candidate config. This is closer to how network devices actually manage configuration, and it enables the config diff workflow.
The Workflow
load_replace_candidate(config)orload_merge_candidate(config)— load your desired configcompare_config()— see what will change (the diff)commit_config()— apply the change- Or
discard_config()— abandon it
new_config = """
hostname spine-01
!
ntp server 192.168.1.1
ntp server 192.168.1.2
!
"""
with driver(hostname="192.168.100.10", username="admin", password="netauto") as device:
device.load_merge_candidate(config=new_config)
diff = device.compare_config()
if diff:
print("Pending changes:")
print(diff)
confirm = input("\nApply? [y/N]: ")
if confirm.lower() == "y":
device.commit_config()
print("Committed.")
else:
device.discard_config()
print("Discarded.")
else:
print("No changes.")
device.discard_config()
Replace vs Merge
load_replace_candidate() — the new config becomes the entire running config. Anything not in the candidate is removed. This is the safest for day-2 operations: you have a complete intended state, and NAPALM makes the device match it exactly.
load_merge_candidate() — the new config is merged into the running config. Useful for additive changes (adding NTP servers, adding a route) where you don’t want to provide the full device config.
For most automation workflows, prefer load_replace_candidate() where the driver supports it. It eliminates configuration drift — the device ends up in exactly the state you specified, regardless of what was there before.
compare_config() Diff Output
The diff format varies slightly by driver, but is always human-readable:
Arista EOS:
+ ntp server 192.168.1.2
Cisco IOS-XE:
+ntp server 192.168.1.2
-ntp server 192.168.0.99
JunOS (unified diff):
[edit system]
+ ntp {
+ server 192.168.1.1;
+ server 192.168.1.2;
+ }
rollback() — Emergency Revert
If a committed change causes problems, rollback() reverts to the previous config:
device.rollback()
Support depends on the driver. EOS has native rollback support. IOS-XE relies on archive config; JunOS has a commit history. Check your driver’s documentation.
Loading Config from a File
Combine NAPALM with the Jinja2 templates from Part 8:
from jinja2 import Environment, FileSystemLoader
import yaml
# Load inventory and render template (same as Part 8)
with open("inventory.yaml") as f:
inventory = yaml.safe_load(f)
env = Environment(loader=FileSystemLoader("templates"), trim_blocks=True, lstrip_blocks=True)
for device_data in inventory["devices"]:
template = env.get_template("eos_config.j2")
rendered_config = template.render(device=device_data)
driver = napalm.get_network_driver("eos")
with driver(hostname=device_data["host"], username="admin", password="netauto") as device:
device.load_replace_candidate(config=rendered_config)
diff = device.compare_config()
if diff:
print(f"\n{device_data['name']}:")
print(diff)
device.commit_config()
else:
device.discard_config()
This is the full automation loop: structured data → Jinja2 template → NAPALM config replace → diff review → commit. It’s the foundation of intent-based networking in pure Python.
Capstone: Multi-Device Audit Script
A script that reads state from all three lab devices and produces a JSON audit report — same code, same output structure, regardless of vendor:
"""NAPALM multi-device audit — collects facts, interfaces, and BGP state."""
import json
import napalm
import sys
from datetime import datetime, timezone
from pathlib import Path
from inventory_loader import load_inventory
OUTPUT_FILE = "napalm_audit.json"
def audit_device(device_entry) -> dict:
"""Collect state from one device via NAPALM."""
driver = napalm.get_network_driver(device_entry.device_type)
with driver(
hostname=device_entry.host,
username=device_entry.username,
password=device_entry.password,
) as device:
facts = device.get_facts()
interfaces = device.get_interfaces()
ip_info = device.get_interfaces_ip()
up_interfaces = [n for n, d in interfaces.items() if d["is_up"]]
ip_summary = {
intf: list(addrs["ipv4"].keys())
for intf, addrs in ip_info.items()
if "ipv4" in addrs
}
return {
"name": device_entry.name,
"host": device_entry.host,
"collected_at": datetime.now(timezone.utc).isoformat(),
"facts": facts,
"up_interfaces": up_interfaces,
"ip_addresses": ip_summary,
}
def main():
devices = load_inventory("inventory.yaml")
print(f"\nAuditing {len(devices)} device(s) via NAPALM...\n")
results, failures = [], []
for d in devices:
print(f" {d.name} ({d.host})... ", end="", flush=True)
try:
data = audit_device(d)
results.append(data)
print(
f"OK — {data['facts']['os_version']} — "
f"{len(data['up_interfaces'])}/{len(data['facts']['interface_list'])} interfaces up"
)
except Exception as e:
failures.append(d.name)
print(f"FAILED — {e}")
output = {
"run_at": datetime.now(timezone.utc).isoformat(),
"collected": len(results),
"failed": failures,
"devices": results,
}
Path(OUTPUT_FILE).write_text(json.dumps(output, indent=2), encoding="utf-8")
print(f"\nAudit saved to {OUTPUT_FILE}")
if failures:
sys.exit(1)
if __name__ == "__main__":
main()
The NAPALM driver string (eos, ios, junos) differs from the Netmiko device_type string. If you’re using the Pydantic inventory model from Part 7, you may want to add a napalm_driver field alongside device_type, or build a mapping:
NAPALM_DRIVER_MAP = {
"arista_eos": "eos",
"cisco_ios": "ios",
"cisco_xe": "ios",
"cisco_xr": "iosxr",
"cisco_nxos": "nxos_ssh",
"juniper_junos": "junos",
}
NAPALM vs Netmiko vs REST API
| Netmiko | REST API | NAPALM | |
|---|---|---|---|
| Vendor support | Broad | Varies | Core 5 + community |
| Output format | String | JSON (vendor schema) | Normalised dict |
| Multi-vendor code | Branching logic | Different per vendor | Same code |
| Config push | Line-by-line | PUT/PATCH | Replace or merge |
| Config diff | Manual | Manual | Built-in |
| Rollback | Manual | Manual | Built-in (driver dependent) |
| Speed | SSH round-trip per command | Batch | Varies by transport |
Use NAPALM when: you need the same code to work across multiple vendors, or when you want the config replace + diff + rollback workflow. Fall back to Netmiko for platforms NAPALM doesn’t support, or for interactive/diagnostic commands that aren’t covered by getters. Use REST directly when a vendor’s API provides capabilities beyond what NAPALM exposes.
What’s Coming in Part 11
Part 11 covers Nornir — a pure-Python automation framework that manages inventory, runs tasks in parallel across devices, and aggregates results. Nornir is the last piece that ties the series together: inventory from Part 7, Netmiko from Part 5, NAPALM from this post, and REST APIs from Part 9 all plug into Nornir as task functions.
This post uses the same Containerlab topology as Parts 5–9. No new lab infrastructure required.