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:

DriverPlatformTransport
eosArista EOSeAPI (HTTP)
iosCisco IOS / IOS-XESSH via Netmiko
iosxrCisco IOS-XRSSH via Netmiko
nxos_sshCisco NX-OSSSH via Netmiko
junosJuniper JunOSNETCONF

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:

DriverUseful 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

GetterReturns
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

  1. load_replace_candidate(config) or load_merge_candidate(config) — load your desired config
  2. compare_config() — see what will change (the diff)
  3. commit_config() — apply the change
  4. 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

NetmikoREST APINAPALM
Vendor supportBroadVariesCore 5 + community
Output formatStringJSON (vendor schema)Normalised dict
Multi-vendor codeBranching logicDifferent per vendorSame code
Config pushLine-by-linePUT/PATCHReplace or merge
Config diffManualManualBuilt-in
RollbackManualManualBuilt-in (driver dependent)
SpeedSSH round-trip per commandBatchVaries 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.