Python Working With NAPALM

Python Working With NAPALM
Table of Contents

At the end of the last part, we looked at the multi-vendor problem. Every time you support a new vendor with Netmiko, you have to remember its command syntax, keep track of the differences, and add more branching logic to your scripts. It works, but it gets messy fast.

NAPALM (Network Automation and Programmability Abstraction Layer with Multivendor support) is a Python library that solves exactly this problem. Instead of sending vendor-specific commands and dealing with the differences yourself, you use NAPALM's standard set of methods, and NAPALM figures out what to do for each platform behind the scenes. Ask NAPALM to get_config(), and it will send the right command to a Cisco IOS device, the right command to a Juniper device, and the right command to an Arista device, all through the same Python method call.

NAPALM currently supports Cisco IOS, Cisco IOS-XR, Cisco NX-OS, Arista EOS, Juniper Junos, and a few others. It is not as broad as Netmiko in terms of vendor coverage, but for the vendors it does support, the abstraction it provides is well worth it.

There is one more thing worth understanding upfront. NAPALM is not a replacement for Netmiko, it actually uses Netmiko (or a vendor's native API) under the hood for many platforms. Think of it as a higher-level layer that sits on top. Netmiko gives you raw SSH access and lets you send any command you want. NAPALM gives you a smaller, cleaner set of pre-defined actions that work the same way across vendors.

In this part, we will install NAPALM, connect to devices, and rewrite our config backup script from the last chapter using NAPALM instead of Netmiko. You will see immediately how much simpler the multi-vendor logic becomes.

Installing NAPALM

NAPALM is an external library, so we install it with PIP into our virtual environment.

pip install napalm

That is it. NAPALM will also install its own set of dependencies, including Netmiko, since it uses Netmiko for some of the platforms it supports.

A Config Backup Script with NAPALM

Here is the same config backup script from the last chapter, rewritten to use NAPALM.

from datetime import datetime
from napalm import get_network_driver

devices = [
    {
        "driver": "ios",
        "hostname": "192.168.202.101",
        "username": "admin",
        "password": "admin"
    },
    {
        "driver": "junos",
        "hostname": "192.168.202.102",
        "username": "admin",
        "password": "admin@123"
    },
    {
        "driver": "eos",
        "hostname": "192.168.202.103",
        "username": "admin",
        "password": "admin"
    }
]

today = datetime.now().strftime("%Y-%m-%d")

for device in devices:
    print(f"Connecting to {device['hostname']}...")

    driver = get_network_driver(device["driver"])

    with driver(
        hostname=device["hostname"],
        username=device["username"],
        password=device["password"]
    ) as connection:
        facts = connection.get_facts()
        hostname = facts["hostname"]
        config = connection.get_config()["running"]

    filename = f"{hostname}_{today}.txt"

    with open(filename, "w") as file:
        file.write(config)

    print(f"Saved config for {hostname} to {filename}")

print("\nBackup complete")

Compare this to the Netmiko version from the last chapter. Notice what is missing, the commands_by_vendor dictionary is gone. There is no mapping of "which command should I run on which vendor". NAPALM handles all of that for us.

get_network_driver(device["driver"]) picks the right NAPALM driver for the platform. Instead of cisco_ios, juniper_junos, and arista_eos, NAPALM uses shorter names like ios, junos, and eos.

connection.get_facts() returns a dictionary of general information about the device, including its hostname, model, serial number, uptime, and more. This is the same method call on every vendor, and it always returns the same set of keys. That is the abstraction at work.

connection.get_config()["running"] returns the running config as a string. The method actually returns a dictionary with keys for running, startup, and candidate, so we grab the running one.

We also use with driver(...) as connection:, which is the same context manager pattern we have been using with Netmiko. NAPALM disconnects for us when the block ends.

Everything else, the loop, the filename generation, the file writing, is exactly the same as before. The vendor-specific logic just melts away, and the script becomes shorter, cleaner, and easier to maintain.

Starting from the Basics

That backup script was a nice preview of what NAPALM can do, but I think I moved fast. Let's take a step back and look at NAPALM properly from the ground up.We will start with a single connection and a single call, then build from there.

Connecting to a Single Device

The first thing to know is that NAPALM does not have one single connect function like Netmiko does. Instead, it has a driver for each supported platform, and you have to pick the right one for your device. You do this with get_network_driver(), which takes a short name like ios, junos, eos, iosxr, or nxos, and returns the driver class for that platform.

Once you have the driver, you use it to build a connection object, then call .open() on it to actually connect. When you are done, you call .close(). Or, better, you use a with block and let NAPALM handle the close for you.

Here is the simplest possible NAPALM script.

from napalm import get_network_driver

driver = get_network_driver("ios")

with driver(
    hostname="192.168.202.101",
    username="admin",
    password="admin"
) as connection:
    print("Connected!")

Output

Connected!

That is it. get_network_driver("ios") gives you the Cisco IOS driver, and the with block opens the connection, runs the code inside, and closes the connection when the block ends. The print statement fires once the connection is established.

The driver names you will use most often are:

  • ios - Cisco IOS
  • iosxr - Cisco IOS-XR
  • nxos - Cisco NX-OS
  • eos - Arista EOS
  • junos - Juniper Junos

Getting Device Facts

Now that we can connect, let's actually ask the device for something. The most common starting point is .get_facts(), which returns a dictionary of general information about the device.

from napalm import get_network_driver

driver = get_network_driver("ios")

with driver(
    hostname="192.168.202.101",
    username="admin",
    password="admin"
) as connection:
    facts = connection.get_facts()
    print(facts)

Output

{'uptime': 420.0, 'vendor': 'Cisco', 'os_version': 'Linux Software (X86_64BI_LINUX-ADVENTERPRISEK9-M), Version 17.12.1, RELEASE SOFTWARE (fc5)', 'serial_number': '1', 'model': 'Unknown', 'hostname': 'r1', 'fqdn': 'r1.lab', 'interface_list': ['Ethernet0/0', 'Ethernet0/1', 'Ethernet0/2', 'Ethernet0/3', 'Loopback0']}

You can print this more cleanly with json.dumps(), which we saw back in Chapter 14.

import json
from napalm import get_network_driver

driver = get_network_driver("ios")

with driver(
    hostname="192.168.202.101",
    username="admin",
    password="admin"
) as connection:
    facts = connection.get_facts()
    print(json.dumps(facts, indent=2))

Output

{
  "uptime": 420.0,
  "vendor": "Cisco",
  "os_version": "Linux Software (X86_64BI_LINUX-ADVENTERPRISEK9-M), Version 17.12.1, RELEASE SOFTWARE (fc5)",
  "serial_number": "1",
  "model": "Unknown",
  "hostname": "r1",
  "fqdn": "r1.lab",
  "interface_list": [
    "Ethernet0/0",
    "Ethernet0/1",
    "Ethernet0/2",
    "Ethernet0/3",
    "Loopback0"
  ]
}

Since this is just a Python dictionary, you can pick out whatever fields you care about.

print(f"Hostname: {facts['hostname']}")
print(f"Vendor: {facts['vendor']}")
print(f"OS Version: {facts['os_version']}")
print(f"Uptime: {facts['uptime']} seconds")

Output

Hostname: r1
Vendor: Cisco
OS Version: Linux Software (X86_64BI_LINUX-ADVENTERPRISEK9-M), Version 17.12.1, RELEASE SOFTWARE (fc5)
Uptime: 480.0 seconds

The important thing to understand is that get_facts() returns the same set of keys no matter which vendor you run it against. Cisco IOS, Junos, EOS, all of them return hostname, vendor, os_version, uptime, and so on. This is the whole point of NAPALM. Your code does not need to know or care which vendor it is talking to. Here is the output for Juniper.

Hostname: r2
Vendor: Juniper
OS Version: 24.2R1-S2.5
Uptime: 472.0 seconds

Transport Options

Let's do the same thing on the Arista device. Here is what it looks like.

import json
from napalm import get_network_driver

driver = get_network_driver("eos")

with driver(
    hostname="192.168.202.103",
    username="admin",
    password="admin"
) as connection:
    facts = connection.get_facts()
    print(f"Hostname: {facts['hostname']}")
    print(f"Vendor: {facts['vendor']}")
    print(f"OS Version: {facts['os_version']}")
    print(f"Uptime: {facts['uptime']} seconds")

Same code as before, only the driver name and the device details have changed. That is the point of NAPALM, the method calls stay the same across vendors.

One thing worth mentioning about Arista, the eos driver connects using eAPI by default, which is Arista's HTTPS-based API. That means eAPI needs to be enabled on the device. In a lab or production setup where eAPI is not enabled (or where you would rather stick with SSH), you can tell NAPALM to use SSH instead by passing an optional argument.

import json
from napalm import get_network_driver

driver = get_network_driver("eos")

with driver(
    hostname="192.168.202.103",
    username="admin",
    password="admin",
    optional_args={"transport": "ssh"}
) as connection:
    facts = connection.get_facts()
    print(f"Hostname: {facts['hostname']}")
    print(f"Vendor: {facts['vendor']}")
    print(f"OS Version: {facts['os_version']}")
    print(f"Uptime: {facts['uptime']} seconds")

Output

Hostname: r3
Vendor: Arista
OS Version: 4.34.2F-43232954.4342F.1
Uptime: 777.4739134311676 seconds

The optional_args={"transport": "ssh"} bit tells NAPALM to fall back to SSH instead of eAPI.

Getting Interface IP Information

Another useful NAPALM method is get_interfaces_ip(), which returns a dictionary of every interface on the device along with any IPv4 and IPv6 addresses configured on them.

Here is what it looks like on the Cisco device.

import json
from napalm import get_network_driver

driver = get_network_driver("ios")

with driver(
    hostname="192.168.202.101",
    username="admin",
    password="admin"
) as connection:
    interfaces_ip = connection.get_interfaces_ip()
    print(json.dumps(interfaces_ip, indent=2))

Output

{
  "Ethernet0/0": {
    "ipv4": {
      "192.168.202.101": {
        "prefix_length": 24
      }
    }
  },
  "Ethernet0/1": {
    "ipv4": {
      "172.16.1.0": {
        "prefix_length": 31
      }
    }
  },
  "Loopback0": {
    "ipv4": {
      "10.0.0.1": {
        "prefix_length": 32
      }
    }
  }
}

The structure is a nested dictionary. The outer keys are interface names, and each interface has an ipv4 key that maps IP addresses to their prefix length. Interfaces without any IP configured are not included in the output.

Because this is just a Python dictionary, you can loop through it like any other. Here is a small example that prints one line per interface with the IP and prefix length.

for interface, addresses in interfaces_ip.items():
    for ip, details in addresses.get("ipv4", {}).items():
        print(f"{interface}: {ip}/{details['prefix_length']}")

Output

Ethernet0/0: 192.168.202.101/24
Ethernet0/1: 172.16.1.0/31
Loopback0: 10.0.0.1/32

Two small things worth explaining here. First, .items() on a dictionary gives you both the key and the value on each loop, which is how we can pull out the interface name and its addresses at the same time. We covered this back in Chapter 4 when we first looked at looping through dictionaries.

Second, we use .get("ipv4", {}) instead of ["ipv4"]. Some interfaces might only have an IPv6 address, so the ipv4 key is not guaranteed to be there. Using .get() with a default empty dictionary is safer, if the key is missing, we just get an empty dictionary back and the inner loop skips that interface quietly. With square brackets, we would get a KeyError and the script would crash.

Now let's run the same script on the Arista device. Since we are using SSH instead of eAPI, we pass the transport in optional_args like before.

import json
from napalm import get_network_driver

driver = get_network_driver("eos")

with driver(
    hostname="192.168.202.103",
    username="admin",
    password="admin",
    optional_args={"transport": "ssh"}
) as connection:
    interfaces_ip = connection.get_interfaces_ip()
    print(json.dumps(interfaces_ip, indent=2))
Ethernet1: 172.16.1.3/31
Ethernet2: 172.16.1.4/31
Loopback0: 10.0.0.3/32
Management0: 192.168.202.103/24

The output structure looks the same (not the values of course) as it did on Cisco.

Pushing Configuration with NAPALM

NAPALM can also push configuration changes, and it does so in a much cleaner way than sending raw commands one by one. Instead of just firing off commands and hoping for the best, NAPALM uses a proper candidate config workflow, load your changes, compare them against the running config, and only commit if you are happy.

The three main methods for this are load_merge_candidate(), compare_config(), and commit_config().

  • load_merge_candidate() takes your config changes and loads them into a candidate config on the device
  • compare_config() returns a diff between the running config and the candidate config, so you can see exactly what will change
  • commit_config() applies the candidate config to the running config

If you want to throw away the changes instead of applying them, there is also discard_config(). This whole workflow gives you a safety net, you always get to see the diff before anything happens. Let's push an NTP server to all three devices.

from napalm import get_network_driver

devices = [
    {
        "driver": "ios",
        "hostname": "192.168.202.101",
        "username": "admin",
        "password": "admin",
        "optional_args": {}
    },
    {
        "driver": "junos",
        "hostname": "192.168.202.102",
        "username": "admin",
        "password": "admin@123",
        "optional_args": {}
    },
    {
        "driver": "eos",
        "hostname": "192.168.202.103",
        "username": "admin",
        "password": "admin",
        "optional_args": {"transport": "ssh"}
    }
]

config_by_vendor = {
    "ios": "ntp server 10.0.0.1",
    "junos": "set system ntp server 10.0.0.1",
    "eos": "ntp server 10.0.0.1"
}

for device in devices:
    print(f"=== Configuring {device['hostname']} ({device['driver']}) ===")

    driver = get_network_driver(device["driver"])

    with driver(
        hostname=device["hostname"],
        username=device["username"],
        password=device["password"],
        optional_args=device["optional_args"]
    ) as connection:
        config = config_by_vendor[device["driver"]]
        connection.load_merge_candidate(config=config)

        diff = connection.compare_config()
        print("Diff:")
        print(diff)

        connection.commit_config()
        print("Committed.\n")

Output

=== Configuring 192.168.202.101 (ios) ===
Diff:
+ntp server 10.0.0.1
Committed.

=== Configuring 192.168.202.102 (junos) ===
Diff:
[edit system]
+   ntp {
+       server 10.0.0.1;
+   }
Committed.

=== Configuring 192.168.202.103 (eos) ===
Diff:
+ntp server 10.0.0.1
Committed.

Look at how consistent the workflow is across all three vendors. Same three method calls, same order. The actual configuration syntax is still vendor-specific (Junos wants set system ntp server, Cisco and Arista want ntp server), but everything around it, the loading, the diffing, the committing, is identical. That is the abstraction earning its keep.

Also notice the diffs. Each vendor formats them a bit differently, Cisco and Arista show plain config lines with a + in front, while Junos shows a more structured view with the config hierarchy. But regardless of the format, you can see exactly what is going to change before you commit. This is huge for anything running against production.

A Note for Cisco IOS

If you are running this against a Cisco IOS device, you may need to enable SCP on the device first. NAPALM uses SCP under the hood to load the candidate config onto Cisco devices, and if SCP is not enabled, you will see an error when you try to load the config. You can enable it with a single command in global config mode.

ip scp server enable

Merge vs Replace

load_merge_candidate() merges your config with what is already on the device. So if you push ntp server 10.0.0.1, and the device already has ntp server 10.0.0.2, both will end up in the config after the commit. This is usually what you want when you are adding new configuration.

There is also load_replace_candidate(), which replaces the entire config on the device with what you provide. This is a much heavier operation and should be used with care, if you get the replacement config wrong, you can lock yourself out of the device or wipe out important configuration. If you are wondering what NAPALM is actually doing behind the scenes to make all this work, here is the short version.

For Arista EOS, NAPALM uses configure sessions. When you call load_merge_candidate(), NAPALM starts a config session (configure session xxx) on the device, loads your changes into it, and holds them there. compare_config() runs a diff between the session (show session-config diff) and the running config, and commit_config() commits the session. If you call discard_config(), NAPALM simply aborts the session, and nothing on the device changes. This works nicely because Arista natively supports config sessions, they are a first-class feature of EOS.

For Junos, it works the same, load_merge_candidate() loads your changes into the candidate config, compare_config() runs a show | compare, and commit_config() runs a commit.

Sure, here you go, a small example just for Cisco before the NAPALM/Netmiko section.

Sending Raw Commands with cli()

Sometimes you need to run a command that NAPALM does not have a getter for, maybe something vendor-specific, or just a quick show command you want to grab the raw output of. NAPALM gives you a method called cli() for exactly this. You pass it a list of commands, and it returns a dictionary where the keys are the commands and the values are the raw output.

Here is a quick example on the Cisco device.

from napalm import get_network_driver

driver = get_network_driver("ios")

with driver(
    hostname="192.168.202.101",
    username="admin",
    password="admin"
) as connection:
    output = connection.cli(["show clock", "show version | include Version"])

    for command, result in output.items():
        print(f"=== {command} ===")
        print(result)
        print()

Output

=== show clock ===
*22:43:01.424 UTC Thu Jul 9 2026

=== show version | include Version ===
Cisco IOS Software [Dublin], Linux Software (X86_64BI_LINUX-ADVENTERPRISEK9-M), Version 17.12.1, RELEASE SOFTWARE (fc5)

You lose the vendor-neutral abstraction here, the output is just raw text, exactly as if you had typed the command yourself, but that is the trade-off.

Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to Packetswitch.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.