Python NAPALM — Multi-Vendor Network Automation

Every vendor speaks a slightly different CLI dialect. A script that retrieves interface statistics from a Cisco IOS router with show interfaces needs to be rewritten entirely for a Juniper or Arista device. NAPALM (Network Automation and Programmability Abstraction Layer with Multivendor support) solves this by providing a single, unified Python API that works identically across Cisco IOS, IOS-XE, IOS-XR, NX-OS, Junos, EOS, and more. A call to get_interfaces() returns the same Python dictionary structure regardless of the vendor underneath — your automation logic never changes, only the driver name does.

Beyond read-only getters, NAPALM implements a candidate configuration workflow — load a change, preview the diff against the running config, commit only when satisfied, and roll back automatically on error. This is safer than issuing raw CLI commands because it gives you a preview before anything changes on the device.

This lab uses Cisco IOS as the target platform. For the lower-level SSH connection layer NAPALM uses internally, see Python Netmiko Show Commands. For the REST-based alternative to SSH-driven automation, see Cisco RESTCONF Basics. For Ansible playbooks that call NAPALM modules see Ansible IOS Configuration. For enabling SSH on the Cisco device that NAPALM connects to, see SSH Configuration.

1. NAPALM Architecture — Core Concepts

How NAPALM Works

NAPALM sits between your Python script and the network device. Your code calls a driver-agnostic method; NAPALM translates that into the appropriate CLI commands or API calls for the target platform, parses the raw output, and returns a normalised Python dictionary. You never see the CLI scraping — only clean structured data:

  Your Python Script
        │
        │  driver.get_facts()          ← same call for any vendor
        ▼
  NAPALM Driver (ios / junos / eos / nxos / iosxr)
        │
        │  Translates to platform-specific commands:
        │    ios:   "show version", "show hostname", "show ip interface brief"
        │    junos: "show version", "show interfaces terse"
        │    eos:   "show version", "show interfaces status"
        ▼
  Network Device  (SSH / NETCONF / eAPI)
        │
        ▼
  Raw CLI / API Output
        │
        ▼
  NAPALM Parser (TextFSM / regex / XML)
        │
        ▼
  Normalised Python Dict  { "hostname": "R1", "vendor": "Cisco", ... }
        │
        ▼
  Your Python Script receives identical structure regardless of vendor
  

Supported Platforms and Driver Names

Driver Name Platform Transport Notes
ios Cisco IOS / IOS-XE SSH (Netmiko) Most common lab target; used throughout this guide
iosxr Cisco IOS-XR SSH (Netmiko) Service provider routers; some getters differ from ios
nxos_ssh Cisco NX-OS SSH Use nxos for NX-API (HTTP); nxos_ssh for SSH
junos Juniper Junos NETCONF Requires junos-eznc package
eos Arista EOS eAPI (HTTP) Requires eAPI enabled on the device

NAPALM vs Netmiko vs RESTCONF

Tool Abstraction Level Returns Best For
NAPALM High — vendor-neutral getters and config workflows Normalised Python dicts Multi-vendor environments; config diff/push workflows; inventory audits
Netmiko Low — SSH helper that sends raw CLI and returns raw text Raw CLI string Single-vendor; custom show commands; quick ad-hoc scripting
RESTCONF Medium — HTTP calls to YANG model endpoints JSON / XML structured data Modern IOS-XE devices; CI/CD pipelines; stateless HTTP integration

2. Lab Topology & Prerequisites

  [Automation Host — Python 3.8+]
  IP: 192.168.10.50
        │
        │  SSH TCP/22
        ▼
  NetsTuts_R1  (Cisco IOS 15.x / IOS-XE)
  IP: 192.168.10.1
  Hostname: NetsTuts_R1
  Username: netauto
  Password: Aut0P@ss!
  Enable:   En@ble99!

  NetsTuts_R2  (Cisco IOS — second device for multi-device examples)
  IP: 192.168.10.2

  Prerequisites on each Cisco device:
    ip domain-name netstuts.com
    crypto key generate rsa modulus 2048
    ip ssh version 2
    username netauto privilege 15 secret Aut0P@ss!
    line vty 0 4
     login local
     transport input ssh
  
SSH must be enabled on the target device before NAPALM can connect. Follow SSH Configuration if it is not already configured. NAPALM's IOS driver uses Netmiko under the hood for the SSH transport layer — ensure the automation host can reach TCP/22 on the device before running any script.

3. Step 1 — Install NAPALM

# ── Install NAPALM and the IOS driver dependencies ───────
pip install napalm

# ── Verify installation ───────────────────────────────────
python3 -c "import napalm; print(napalm.__version__)"
4.1.0

# ── Install TextFSM for enhanced IOS parsing (recommended)
pip install textfsm ntc-templates
  
A single pip install napalm installs all built-in drivers including the ios, iosxr, nxos, and eos drivers. The junos driver requires the additional junos-eznc package. TextFSM and ntc-templates enhance the IOS driver's parsing accuracy for show commands that NAPALM processes internally — strongly recommended for production use.

4. Step 2 — Connect to a Device

Every NAPALM script follows the same three-line connection pattern: get the driver class, instantiate it with connection parameters, and call open(). Always close the connection in a finally block to ensure the SSH session is released even if an exception occurs:

#!/usr/bin/env python3
"""napalm_connect.py — Basic NAPALM connection to Cisco IOS"""

import napalm

# ── Step 1: Get the driver class for the target platform ──
driver = napalm.get_network_driver("ios")

# ── Step 2: Instantiate with connection parameters ────────
device = driver(
    hostname="192.168.10.1",
    username="netauto",
    password="Aut0P@ss!",
    optional_args={
        "secret": "En@ble99!",    # enable password for IOS
        "port": 22,               # SSH port (default 22)
        "conn_timeout": 10,       # connection timeout in seconds
        "global_delay_factor": 2, # slow device? increase this
    }
)

# ── Step 3: Open the connection ───────────────────────────
try:
    device.open()
    print(f"Connected to {device.hostname}")

    # ... getters and config operations go here ...

finally:
    device.close()
    print("Connection closed")
  
The optional_args dictionary passes driver-specific parameters through to the underlying transport. For the IOS driver, secret is the enable password — without it NAPALM cannot enter privileged EXEC mode and most getters will fail with a permission error. The global_delay_factor multiplies all internal timing delays — increase to 2 or 3 for slow lab devices or high-latency WAN connections. The try/finally pattern ensures device.close() always runs even if a getter raises an exception mid-script. See SSH Configuration for the prerequisite device configuration.

5. Step 3 — Retrieving Data with Getters

NAPALM getters are methods prefixed with get_. Each returns a normalised Python dictionary or list of dictionaries. The structure is identical across all supported platforms — the same key names appear whether the device is Cisco, Juniper, or Arista.

get_facts() — Device Summary

#!/usr/bin/env python3
"""napalm_facts.py — Retrieve device facts"""

import napalm
import json

driver = napalm.get_network_driver("ios")
device = driver(hostname="192.168.10.1", username="netauto",
                password="Aut0P@ss!", optional_args={"secret": "En@ble99!"})

try:
    device.open()
    facts = device.get_facts()
    print(json.dumps(facts, indent=2))
finally:
    device.close()
  
# ── Output ────────────────────────────────────────────────
{
  "hostname": "NetsTuts_R1",
  "fqdn": "NetsTuts_R1.netstuts.com",
  "vendor": "Cisco",
  "model": "CSR1000V",
  "os_version": "IOS-XE Software, Version 16.09.05",
  "serial_number": "9TKUWGKFSTJ",
  "uptime": 432000,
  "interface_list": [
    "GigabitEthernet1",
    "GigabitEthernet2",
    "Loopback0"
  ]
}
  
get_facts() runs show version and show interfaces internally, parses the output, and returns a normalised dictionary with eight standard keys. The same eight keys appear for every supported vendor — hostname, fqdn, vendor, model, os_version, serial_number, uptime (in seconds), and interface_list. This is ideal for building a multi-vendor device inventory: loop across a list of hosts, call get_facts() on each, and write the results to a CSV or database.

get_interfaces() — Interface Status and Counters

    interfaces = device.get_interfaces()
    print(json.dumps(interfaces, indent=2))
  
# ── Output (truncated to two interfaces) ─────────────────
{
  "GigabitEthernet1": {
    "is_up": true,
    "is_enabled": true,
    "description": "WAN Uplink",
    "last_flapped": 432000.0,
    "speed": 1000,
    "mtu": 1500,
    "mac_address": "00:1A:2B:3C:4D:5E"
  },
  "GigabitEthernet2": {
    "is_up": true,
    "is_enabled": true,
    "description": "LAN",
    "last_flapped": 432000.0,
    "speed": 1000,
    "mtu": 1500,
    "mac_address": "00:1A:2B:3C:4D:5F"
  }
}
  

get_interfaces_ip() — IP Address Assignments

    ip_info = device.get_interfaces_ip()
    print(json.dumps(ip_info, indent=2))
  
# ── Output ────────────────────────────────────────────────
{
  "GigabitEthernet1": {
    "ipv4": {
      "192.168.10.1": {
        "prefix_length": 24
      }
    }
  },
  "Loopback0": {
    "ipv4": {
      "10.0.0.1": {
        "prefix_length": 32
      }
    },
    "ipv6": {
      "2001:db8::1": {
        "prefix_length": 128
      }
    }
  }
}
  
get_interfaces_ip() uses show ip interface brief internally. Note the ipv6 key on Loopback0 — NAPALM reports both IPv4 and IPv6 addresses in the same structure.

get_route_to() — Routing Table Lookup

    # ── Check best path to a specific destination ────────
    routes = device.get_route_to(destination="0.0.0.0/0")
    print(json.dumps(routes, indent=2))
  
# ── Output ────────────────────────────────────────────────
{
  "0.0.0.0/0": [
    {
      "protocol": "static",
      "inactive": false,
      "age": "never",
      "next_hop": "203.0.113.1",
      "outgoing_interface": "GigabitEthernet1",
      "preference": 1,
      "metric": 0,
      "current_active": true
    }
  ]
}
  
get_route_to() wraps show ip route [dest], accepts a destination in CIDR notation and returns all matching routes including recursive next-hops and protocol source. To retrieve the entire routing table, call get_route_to(destination="") with an empty string — this returns all prefixes, which can be large on production devices. For targeted checks (e.g. verifying a default route exists after a config change), pass the specific prefix.

get_bgp_neighbors() — BGP Peer State

    bgp = device.get_bgp_neighbors()
    for vrf, vrf_data in bgp.items():
        for peer_ip, peer_data in vrf_data["peers"].items():
            print(f"VRF: {vrf}  Peer: {peer_ip}  "
                  f"State: {'Up' if peer_data['is_up'] else 'Down'}  "
                  f"Prefixes: {peer_data['address_family']['ipv4']['received_prefixes']}")
  
# ── Output ────────────────────────────────────────────────
VRF: global  Peer: 203.0.113.1  State: Up  Prefixes: 847231
  

Full Getter Reference

Getter Method Returns Underlying IOS Command(s)
get_facts() Hostname, vendor, model, OS version, serial, uptime, interfaces show version, show interfaces
get_interfaces() Per-interface up/enabled status, speed, MTU, MAC, last flap show interfaces
get_interfaces_ip() IPv4 and IPv6 address + prefix length per interface show ip interface brief, show ipv6 interface brief
get_interfaces_counters() Tx/Rx packets, bytes, errors, drops per interface show interfaces
get_route_to(destination) Matching routes — protocol, next-hop, interface, metric, preference show ip route [dest]
get_bgp_neighbors() BGP peer state, AS number, prefix counts per AFI per VRF show bgp summary, show bgp neighbors
get_lldp_neighbors() LLDP neighbour hostname and port per local interface show lldp neighbors
get_lldp_neighbors_detail() Full LLDP detail — system description, capabilities, management IP show lldp neighbors detail
get_arp_table() ARP entries — IP, MAC, interface, age show arp
get_mac_address_table() MAC table — MAC, VLAN, interface, type (static/dynamic) show mac address-table
get_snmp_information() SNMP community strings, contact, location, chassis ID show snmp
get_ntp_peers() Configured NTP server addresses show ntp associations. See NTP Configuration.
get_config(retrieve) Running, startup, or candidate config as a raw string show running-config, show startup-config
get_environment() CPU load, memory usage, temperature, power supply, fan state show processes cpu, show environment
get_vlans() VLAN IDs, names, and member interfaces show vlan brief

6. Step 4 — Multi-Device Inventory Script

The real value of NAPALM's normalised output is looping across multiple devices with identical code regardless of vendor. This script collects facts and interface IP addresses from every device in a YAML inventory file and writes the results to a CSV:

inventory.yaml

# inventory.yaml ── device list for NAPALM scripts
devices:
  - hostname: "192.168.10.1"
    driver: "ios"
    username: "netauto"
    password: "Aut0P@ss!"
    secret: "En@ble99!"
    site: "HQ"

  - hostname: "192.168.10.2"
    driver: "ios"
    username: "netauto"
    password: "Aut0P@ss!"
    secret: "En@ble99!"
    site: "Branch1"

  - hostname: "192.168.20.1"
    driver: "eos"
    username: "admin"
    password: "Ar1sta!"
    secret: ""
    site: "DataCentre"
  

napalm_inventory.py

#!/usr/bin/env python3
"""napalm_inventory.py — Collect facts from all devices in inventory"""

import napalm
import yaml
import csv
import json
from datetime import datetime

def collect_device_facts(dev_cfg):
    """Connect to one device and return its facts dict, or None on error."""
    driver = napalm.get_network_driver(dev_cfg["driver"])
    device = driver(
        hostname=dev_cfg["hostname"],
        username=dev_cfg["username"],
        password=dev_cfg["password"],
        optional_args={"secret": dev_cfg.get("secret", "")}
    )
    try:
        device.open()
        facts = device.get_facts()
        facts["site"] = dev_cfg["site"]          # add our own field
        facts["collected_at"] = datetime.now().isoformat()
        return facts
    except Exception as exc:
        print(f"  ERROR connecting to {dev_cfg['hostname']}: {exc}")
        return None
    finally:
        device.close()

# ── Load inventory ────────────────────────────────────────
with open("inventory.yaml") as f:
    inventory = yaml.safe_load(f)

# ── Collect facts from every device ──────────────────────
all_facts = []
for dev in inventory["devices"]:
    print(f"Connecting to {dev['hostname']} ({dev['site']})...")
    facts = collect_device_facts(dev)
    if facts:
        all_facts.append(facts)
        print(f"  OK — {facts['hostname']}  {facts['vendor']} {facts['model']}")

# ── Write CSV report ──────────────────────────────────────
csv_fields = ["hostname", "vendor", "model", "os_version",
              "serial_number", "uptime", "site", "collected_at"]

with open("device_inventory.csv", "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=csv_fields, extrasaction="ignore")
    writer.writeheader()
    writer.writerows(all_facts)

print(f"\nInventory written to device_inventory.csv ({len(all_facts)} devices)")
  
# ── Sample output ─────────────────────────────────────────
Connecting to 192.168.10.1 (HQ)...
  OK — NetsTuts_R1  Cisco CSR1000V
Connecting to 192.168.10.2 (Branch1)...
  OK — NetsTuts_R2  Cisco ISR4331
Connecting to 192.168.20.1 (DataCentre)...
  OK — DC-LEAF1  Arista DCS-7050CX3

Inventory written to device_inventory.csv (3 devices)
  
The identical get_facts() call works for both Cisco IOS and Arista EOS devices — the script does not need any vendor-specific branching logic. The extrasaction="ignore" on the CSV writer discards extra keys (like interface_list) that are not in the csv_fields list. For large inventories, replace the sequential loop with Python's concurrent.futures.ThreadPoolExecutor to connect to multiple devices in parallel — significantly faster for 50+ devices.

7. Step 5 — Compare Configuration States

NAPALM's get_config() retrieves the running, startup, or candidate configuration as a string. Combined with Python's difflib, you can produce a human-readable diff between two config states — useful for change audits, drift detection, or verifying that a deployment applied exactly the intended changes. See Saving and Managing Cisco Configurations for understanding the relationship between running-config and startup-config:

Compare Running vs Startup Config (Detect Unsaved Changes)

#!/usr/bin/env python3
"""napalm_config_diff.py — Detect unsaved changes (running vs startup)"""

import napalm
import difflib

driver = napalm.get_network_driver("ios")
device = driver(hostname="192.168.10.1", username="netauto",
                password="Aut0P@ss!", optional_args={"secret": "En@ble99!"})

try:
    device.open()

    # ── Retrieve both configs ─────────────────────────────
    configs = device.get_config(retrieve="all")
    running  = configs["running"].splitlines(keepends=True)
    startup  = configs["startup"].splitlines(keepends=True)

    # ── Generate unified diff ─────────────────────────────
    diff = list(difflib.unified_diff(
        startup, running,
        fromfile="startup-config",
        tofile="running-config",
        lineterm=""
    ))

    if diff:
        print("UNSAVED CHANGES DETECTED:\n")
        print("".join(diff))
    else:
        print("Running and startup configs are identical — no unsaved changes.")

finally:
    device.close()
  
# ── Sample output when unsaved changes exist ──────────────
UNSAVED CHANGES DETECTED:

--- startup-config
+++ running-config
@@ -42,6 +42,9 @@
 interface GigabitEthernet2
  ip address 192.168.10.1 255.255.255.0
  no shutdown
+!
+ip route 0.0.0.0 0.0.0.0 203.0.113.1
+!
  line vty 0 4
   login local
  
Lines prefixed with + exist in the running config but not in startup — these are changes that would be lost on a reload. Lines prefixed with - exist in startup but not running — these were removed since the last wr. This script can run as a scheduled task across all devices to detect configuration drift and alert when unsaved changes accumulate — a common compliance requirement in production networks. Compare with show running-config output to manually verify any findings.

8. Step 6 — Push Configuration Changes

NAPALM's configuration push workflow is safer than raw CLI because it separates loading a change from committing it. You load the candidate configuration, inspect the diff, and only commit after confirming the diff is exactly what you intended. On platforms that support atomic commits (Junos, EOS, IOS-XR), the change is applied in a single transaction — partial application is impossible. On IOS, NAPALM merges the candidate line-by-line using Netmiko but still provides the diff preview before applying:

Workflow Overview

  load_merge_candidate(config=...) or load_replace_candidate(config=...)
        │
        ▼
  compare_config()   ← returns unified diff (running vs candidate)
        │
        ├── diff is empty → no change needed, discard_config()
        │
        ├── diff looks wrong → discard_config() and fix the candidate
        │
        └── diff is correct ──►  commit_config()
                                       │
                                       ▼
                              Changes applied to device
                              (rollback_config() available if needed)
  

load_merge_candidate — Add Lines to Existing Config

#!/usr/bin/env python3
"""napalm_push_merge.py — Merge new config lines into running config"""

import napalm

# ── Configuration snippet to push ────────────────────────
# load_merge_candidate ADDS these lines — it does not replace
# the entire config. Existing lines are preserved.
NEW_CONFIG = """
ntp server 216.239.35.0
ntp server 216.239.35.4
logging host 192.168.99.10
logging trap informational
ip access-list standard MGMT-ACCESS
 permit 192.168.10.0 0.0.0.255
 deny any log
"""

driver = napalm.get_network_driver("ios")
device = driver(hostname="192.168.10.1", username="netauto",
                password="Aut0P@ss!", optional_args={"secret": "En@ble99!"})

try:
    device.open()

    # ── Stage the candidate config ────────────────────────
    device.load_merge_candidate(config=NEW_CONFIG)

    # ── Preview the diff before committing ───────────────
    diff = device.compare_config()

    if not diff:
        print("No changes detected — config already applied.")
        device.discard_config()
    else:
        print("Pending changes:\n")
        print(diff)
        confirm = input("\nApply these changes? [yes/no]: ")
        if confirm.lower() == "yes":
            device.commit_config()
            print("Configuration committed successfully.")
        else:
            device.discard_config()
            print("Changes discarded — no modifications made.")

except Exception as exc:
    print(f"Error: {exc}")
    device.discard_config()  # always discard on error

finally:
    device.close()
  
# ── Sample output ─────────────────────────────────────────
Pending changes:

+ntp server 216.239.35.0
+ntp server 216.239.35.4
+logging host 192.168.99.10
+logging trap informational
+ip access-list standard MGMT-ACCESS
+ permit 192.168.10.0 0.0.0.255
+ deny any log

Apply these changes? [yes/no]: yes
Configuration committed successfully.
  
load_merge_candidate() stages the config without applying it. compare_config() returns a unified diff showing only the lines that will be added (+) or removed (-) — it does not return the entire running config. The config snippet above adds NTP servers, a syslog host, and an access list. The interactive confirmation step is optional but strongly recommended for production scripts. For fully automated pipelines (CI/CD), replace the input() call with a programmatic check: if the diff matches the expected change template, commit automatically; otherwise raise an alert.

load_replace_candidate — Replace the Entire Config

#!/usr/bin/env python3
"""napalm_push_replace.py — Replace entire config from a file"""

import napalm

driver = napalm.get_network_driver("ios")
device = driver(hostname="192.168.10.1", username="netauto",
                password="Aut0P@ss!", optional_args={"secret": "En@ble99!"})

try:
    device.open()

    # ── Load candidate from a full config file ────────────
    # load_replace_candidate REPLACES the entire running config.
    # Lines in running but NOT in the candidate will be REMOVED.
    # Use with extreme caution — always review the diff first.
    device.load_replace_candidate(filename="r1_desired_state.cfg")

    diff = device.compare_config()
    print("Diff (- lines will be REMOVED, + lines will be ADDED):\n")
    print(diff)

    confirm = input("\nThis will REPLACE the running config. Proceed? [yes/no]: ")
    if confirm.lower() == "yes":
        device.commit_config()
        print("Replace committed.")
    else:
        device.discard_config()
        print("Discarded — no changes made.")

except Exception as exc:
    print(f"Error: {exc}")
    device.discard_config()

finally:
    device.close()
  
load_replace_candidate() is the declarative approach — the candidate file represents the complete intended state of the device. Any line in the running config that is not in the candidate file will be removed on commit. This is powerful but dangerous: a missing ip route 0.0.0.0 0.0.0.0 line in the candidate file will delete the default route on commit, cutting off management access. Always review the full diff — especially - lines — before committing a replace operation. For Cisco IOS, replace is implemented via configure replace — confirm your IOS version supports this feature before use. See Saving and Managing Cisco Configurations for how to generate a complete config baseline file.

rollback_config() — Undo the Last Commit

    # ── Rollback immediately after a bad commit ───────────
    device.open()
    device.rollback_config()
    print("Rolled back to pre-commit state.")
    device.close()
  
rollback_config() restores the configuration that existed immediately before the last commit_config() call. On IOS, NAPALM saves the pre-commit running config to a rollback file and applies it via configure replace. On Junos (using NETCONF) and EOS, rollback uses the platform's native transactional commit history. Note that rollback only covers the most recent commit — it is not a full commit history. For production safety, save the pre-change config with get_config(retrieve="running") before committing so you always have a known-good baseline to restore. Compare with show running-config to verify the rollback was successful.

9. Step 7 — Automated Compliance Checking

A practical pattern is combining getters to verify network-wide standards. This script checks a list of compliance rules against every device in the inventory and reports violations — useful for security audits, change verification, or daily health checks:

#!/usr/bin/env python3
"""napalm_compliance.py — Check devices against compliance rules"""

import napalm
import yaml

COMPLIANCE_RULES = {
    "ntp_servers": ["216.239.35.0", "216.239.35.4"],  # required NTP peers
    "min_uptime_hours": 1,                             # flag if just rebooted
    "required_interfaces_up": ["GigabitEthernet1"],    # must be up
}

def check_device(dev_cfg):
    driver  = napalm.get_network_driver(dev_cfg["driver"])
    device  = driver(
        hostname=dev_cfg["hostname"],
        username=dev_cfg["username"],
        password=dev_cfg["password"],
        optional_args={"secret": dev_cfg.get("secret", "")}
    )
    violations = []

    try:
        device.open()
        facts      = device.get_facts()
        ntp_peers  = device.get_ntp_peers()
        interfaces = device.get_interfaces()

        # ── Rule 1: Uptime check ──────────────────────────
        uptime_hours = facts["uptime"] / 3600
        if uptime_hours < COMPLIANCE_RULES["min_uptime_hours"]:
            violations.append(
                f"LOW UPTIME: {uptime_hours:.1f}h — possible unplanned reboot"
            )

        # ── Rule 2: NTP servers present ───────────────────
        configured_ntp = list(ntp_peers.keys())
        for required in COMPLIANCE_RULES["ntp_servers"]:
            if required not in configured_ntp:
                violations.append(f"MISSING NTP SERVER: {required}")

        # ── Rule 3: Critical interfaces must be up ────────
        for intf in COMPLIANCE_RULES["required_interfaces_up"]:
            if intf in interfaces:
                if not interfaces[intf]["is_up"]:
                    violations.append(f"INTERFACE DOWN: {intf}")
            else:
                violations.append(f"INTERFACE NOT FOUND: {intf}")

        return facts["hostname"], violations

    except Exception as exc:
        return dev_cfg["hostname"], [f"CONNECTION ERROR: {exc}"]
    finally:
        device.close()

# ── Run against all devices ───────────────────────────────
with open("inventory.yaml") as f:
    inventory = yaml.safe_load(f)

print("=" * 60)
print("COMPLIANCE REPORT")
print("=" * 60)
all_pass = True

for dev in inventory["devices"]:
    hostname, violations = check_device(dev)
    if violations:
        all_pass = False
        print(f"\n[FAIL] {hostname}")
        for v in violations:
            print(f"       ✗ {v}")
    else:
        print(f"[PASS] {hostname}")

print("\n" + ("ALL DEVICES COMPLIANT" if all_pass else "VIOLATIONS FOUND"))
  
# ── Sample output ─────────────────────────────────────────
============================================================
COMPLIANCE REPORT
============================================================
[PASS] NetsTuts_R1
[PASS] DC-LEAF1

[FAIL] NetsTuts_R2
       ✗ MISSING NTP SERVER: 216.239.35.4
       ✗ INTERFACE DOWN: GigabitEthernet1

VIOLATIONS FOUND
  
The compliance script uses get_ntp_peers() to verify NTP server configuration and get_interfaces() to check interface states. For interface failures flagged here, investigate with show interfaces on the device directly. For missing NTP servers, see NTP Configuration.

10. Troubleshooting NAPALM Connections

Error Cause Fix
ConnectionException: Unable to connect TCP/22 not reachable — firewall blocking SSH, device not listening, wrong IP Ping the device from the automation host. Verify ip ssh version 2 and transport input ssh on VTY lines. Check SSH Configuration.
AuthenticationException Wrong username or password in the driver instantiation Verify credentials directly with an SSH client first. Check username and privilege 15 on the device. See SSH Configuration.
Getters return empty dicts or missing keys Device did not enter privileged EXEC mode — enable password not provided or wrong Pass "secret": "enable-password" in optional_args. Verify by SSHing manually and running enable with the same password. See show running-config to confirm privilege level.
ReadTimeout on slow devices Device takes longer to respond than the default timeout allows Increase "global_delay_factor": 3 or "conn_timeout": 30 in optional_args. Also try "fast_cli": False to disable fast-mode parsing. See Python Netmiko for underlying timing behaviour.
compare_config() returns empty string Candidate config was not loaded (no load_merge_candidate called), or config was already discarded Ensure load_merge_candidate() or load_replace_candidate() is called before compare_config(). Check that discard_config() was not called earlier in the same session.
MergeConfigException on IOS One or more config lines in the candidate were rejected by IOS syntax validation NAPALM applies lines one at a time on IOS — check which line caused the error. Validate the candidate snippet manually in the CLI before pushing via NAPALM. Use show running-config to compare after manual testing.
load_replace_candidate cuts connectivity Candidate config file missing management interface IP, SSH config, or default route Always include all required lines in replace candidates: IP address on management interface, VTY SSH config, default route, and local username. Preview diff for - lines before committing. See Saving Cisco Configurations.

Key Points & Exam Tips

  • NAPALM provides a unified API across multiple vendors — the same Python method calls work for Cisco IOS, NX-OS, Junos, and Arista EOS. Only the driver name changes between platforms. See Controller-Based Networking for the broader context.
  • The standard connection pattern is: get_network_driver() → instantiate driver → open() → run getters/config operations → close() in a finally block.
  • For Cisco IOS, always pass "secret": "enable-password" in optional_args — without it the driver cannot enter privileged EXEC mode and most getters will return empty or incomplete data.
  • get_facts() returns eight normalised keys: hostname, fqdn, vendor, model, os_version, serial_number, uptime, and interface_list — identical structure for every supported platform. Underlying IOS commands: show version and show interfaces.
  • The config push workflow is: load_merge_candidate() (or load_replace_candidate()) → compare_config() → review diff → commit_config() or discard_config().
  • load_merge_candidate() adds lines to the existing config. load_replace_candidate() replaces the entire config — lines in running but not in the candidate file will be removed. Always review the diff for - lines before a replace commit. See Saving and Managing Cisco Configurations.
  • Always call discard_config() in exception handlers — a staged candidate that is never committed or discarded can cause unexpected behaviour on subsequent config operations in the same session.
  • NAPALM is vendor-neutral at the Python API layer but the underlying IOS driver uses Netmiko for SSH. Connectivity issues should be diagnosed at the Netmiko/SSH layer first — if manual SSH fails, NAPALM will also fail.
  • For the CCNA exam and automation track: know the difference between merge vs replace candidates, the role of compare_config() as a safety gate before committing, and how NAPALM's abstraction layer differs from raw Netmiko scripting.
Next Steps: For lower-level SSH scripting without the NAPALM abstraction layer, see Python Netmiko Show Commands. For REST-based programmatic configuration using YANG models, see Cisco RESTCONF Basics. For orchestrating NAPALM calls across device groups using playbooks, see Ansible IOS Configuration. For the controller-based networking model that NAPALM fits within, see Controller-Based Networking and Northbound and Southbound APIs. For the NETCONF/YANG transport used by the Junos driver, see that dedicated guide.

TEST WHAT YOU LEARNED

1. What is the primary advantage of NAPALM over using Netmiko directly for multi-vendor network automation?

Correct answer is D. NAPALM's core value proposition is vendor neutrality at the API layer. A script calling get_interfaces() receives the same Python dictionary structure — same key names, same data types — whether the device is a Cisco IOS router, a Juniper MX series, or an Arista switch. Netmiko is excellent for single-vendor environments where you need to send arbitrary CLI commands, but it returns raw text that requires vendor-specific parsing logic. NAPALM actually uses Netmiko under the hood for IOS devices — it adds a structured abstraction layer on top. For the broader automation context, see Northbound and Southbound APIs.

2. A NAPALM script connects to a Cisco IOS device but all getters return empty dictionaries. The SSH connection succeeds. What is the most likely cause?

Correct answer is B. The IOS NAPALM driver uses Netmiko to SSH into the device and then issues enable to enter privileged EXEC mode before running any show commands. If the enable password is not provided (or is wrong), the driver cannot elevate from user EXEC to privileged EXEC. In user EXEC mode, commands like show version may return partial output or be rejected, causing getters to return empty or incomplete dictionaries without raising an obvious exception. The fix is straightforward: pass optional_args={"secret": "enable-password"} when instantiating the driver. NAPALM is synchronous (not async), and TextFSM improves parsing quality but its absence causes degraded results, not empty dictionaries.

3. What is the difference between load_merge_candidate() and load_replace_candidate() in NAPALM?

Correct answer is A. This distinction is critical for production safety. Merge is additive — think of it as pasting additional config lines into the device. Replace is declarative — the candidate file IS the desired state, and anything not in that file gets removed. A merge candidate missing a default route leaves the existing default route intact. A replace candidate missing a default route will delete the default route on commit, potentially severing management connectivity to the device. Both methods stage the config without applying it — both require a subsequent commit_config() call. Both accept either a config string parameter or a filename parameter. Both support compare_config() to preview the diff before committing. See Saving and Managing Cisco Configurations for how to generate a complete baseline config file.

4. What does compare_config() return in the NAPALM config push workflow, and when should it return an empty string?

Correct answer is C. compare_config() is the safety gate in the NAPALM config push workflow. It computes the diff between the staged candidate and the current running configuration and returns it as a unified diff string. Lines prefixed with + will be added; lines prefixed with - will be removed. An empty string return value means the candidate is identical to running — the change is already applied (idempotent behaviour). In automated scripts, check if not diff: before committing to avoid unnecessary commit operations. Compare the diff output against show running-config to manually verify any findings.

5. A NAPALM script calls get_facts() on three devices — one Cisco IOS router, one Arista EOS switch, and one Juniper MX router. Which statement about the returned data is correct?

Correct answer is B. NAPALM's contract is that every getter method returns the same structure regardless of platform. get_facts() always returns exactly eight keys with specific data types — hostname (string), uptime (float in seconds), interface_list (list of strings), etc. The values reflect the actual device — the vendor field will be "Cisco" for IOS, "Arista" for EOS, "Juniper" for Junos — but the dictionary structure and key names are identical. This normalisation is precisely what makes NAPALM valuable for multi-vendor scripts.

6. Why should device.close() always be placed in a finally block rather than at the end of a script?

Correct answer is D. Python's exception handling skips all code after the line that raised the exception within the try block. If a getter fails and throws an exception, any sequential device.close() after the try block is bypassed — the SSH connection remains open. Network devices have a finite number of VTY lines (typically 5–16 — see SSH Configuration). If scripts leak connections repeatedly, the device's VTY lines fill up and new SSH connections are rejected, locking out all management access including manual troubleshooting. The finally clause executes unconditionally — making it the correct and only safe place for cleanup code like device.close().

7. A script calls device.load_replace_candidate(filename="new_config.cfg") and then device.commit_config(). After the commit, the engineer loses SSH access to the device. What is the most likely cause?

Correct answer is A. This is the most dangerous failure mode of load_replace_candidate(). Unlike merge (which only adds), replace removes every line from running config that is not present in the candidate file. A candidate file that was built by copying a partial config snippet — rather than a complete device config — will be missing the lines that provide management access: the management interface IP, the default route to reach that IP, the VTY line configuration (transport input ssh, login local), and the local username. When the replace commits, all of these are deleted and the device becomes unreachable. Recovery requires console access. The correct practice is: always run compare_config() and carefully review all - lines before committing a replace, and ensure the candidate file is a complete, bootable configuration. See Saving and Managing Cisco Configurations.

8. Which NAPALM getter would you use to verify that a BGP peer at 203.0.113.1 is established and has received more than 100,000 prefixes?

Correct answer is C. get_bgp_neighbors() returns a nested dictionary structure organised by VRF, then by peer IP address, then by peer attributes. For each peer it includes is_up (whether the session is established), remote_as, uptime, and an address_family dict with per-AFI prefix counts including received_prefixes, sent_prefixes, and accepted_prefixes. This is the correct tool for BGP session monitoring. get_route_to() uses show ip route to check the routing table for a specific destination prefix, not BGP peer state.

9. A NAPALM script raises ReadTimeout when calling get_config(retrieve="running") on a large router with a 10,000-line running config. How should this be fixed?

Correct answer is D. ReadTimeout in NAPALM's IOS driver occurs when Netmiko (the underlying SSH library) does not receive the expected end-of-output pattern within the configured timeout window. For large configurations, the device takes several seconds to output all 10,000 lines, and the default timing may expire before the output completes. global_delay_factor is a multiplier applied to all of Netmiko's internal timing values — increasing it from the default 1 to 2 or 3 doubles or triples all waiting periods. fast_cli: False disables Netmiko's fast mode which uses aggressive timing assumptions. There is no page-based retrieval for config in NAPALM, and get_facts() does not retrieve the full running config.

10. An engineer wants to run a compliance check across 50 devices and collect get_facts() output from each. The sequential script takes 4 minutes. Which Python approach reduces this to under 30 seconds without changing the NAPALM driver code?

Correct answer is C. Network automation scripts are almost entirely I/O-bound — each thread spends most of its time waiting for SSH responses from the device. The Python GIL (Global Interpreter Lock), which limits CPU-bound multiprocessing efficiency, has negligible impact on I/O-bound work. ThreadPoolExecutor from concurrent.futures allows multiple SSH sessions to proceed in parallel, each waiting independently. With 10–20 workers and 50 devices, the total time drops from sequential sum (4 min) to roughly max-single-device time (under 30 sec). NAPALM does not natively support async/await (Option A) — it is a synchronous library. There is no built-in parallel mode in NAPALM (Option B). For orchestrating at even larger scale, consider Ansible IOS Configuration.