Auto Draft

Network Automation with Python: Netmiko, NAPALM, and Nornir — A Practical Guide

If you’re still logging into network devices one by one to make changes, you’re leaving enormous amounts of time on the table. I’ve been a network engineer for over a decade, and nothing has changed my daily workflow more than getting serious about Python automation. Not Ansible, not vendor GUIs — raw Python, talking directly to devices.

In this guide I’m going to walk you through the three libraries that form the backbone of Python-based network automation: Netmiko, NAPALM, and Nornir. By the end, you’ll have working scripts that can connect to Cisco IOS/IOS-XE devices, pull show command output, push configuration changes, and run tasks across an entire fleet simultaneously. I’ll include real, tested examples — the kind you can adapt immediately for your environment.

If you’re more comfortable with YAML-based workflows, check out our Ansible for Homelab guide for a declarative alternative. But if you want the full flexibility of code, Python is where it’s at.

Why Python Over CLI Scripts?

Bash + expect scripts work, but they’re brittle. Python gives you:

  • Error handling: Catch authentication failures, timeouts, and unexpected output gracefully
  • Data manipulation: Parse show command output with TextFSM or Genie, turn it into structured data
  • Parallel execution: Run tasks against 50 devices simultaneously — Nornir’s threading makes this trivial
  • Idempotency: Check state before pushing config, avoid unnecessary changes
  • Integration: Pipe results into spreadsheets, databases, ITSM tickets, Slack alerts

Setting Up Your Environment

I recommend a dedicated virtualenv for network automation tools:

# Create and activate a virtual environment
python3 -m venv netauto
source netauto/bin/activate

# Install the core libraries
pip install netmiko napalm nornir nornir-netmiko nornir-utils

# Useful extras
pip install textfsm ntc-templates rich pandas

Your lab needs at minimum one Cisco IOS or IOS-XE device (physical or virtual — GNS3/EVE-NG work perfectly). For multi-vendor testing, spin up an Arista EOS or Juniper vSRX alongside your Cisco gear.

Netmiko: The Foundation

Netmiko is Kirk Byers’ SSH library specifically built for network devices. It handles the quirks that plague generic SSH libraries: irregular prompts, enable mode, terminal width issues, slow devices. It supports 80+ device types out of the box.

Basic Connection and Show Commands

from netmiko import ConnectHandler

# Define the device dictionary
cisco_device = {
    'device_type': 'cisco_ios',
    'host': '192.168.1.1',
    'username': 'admin',
    'password': 'cisco123',
    'secret': 'enable_pass',   # enable password
    'timeout': 30,
}

# Connect and run a show command
with ConnectHandler(**cisco_device) as net_connect:
    net_connect.enable()  # enter enable mode
    output = net_connect.send_command('show version')
    print(output)

The context manager (with statement) handles connection teardown automatically — always use it. Here’s what real output looks like:

Cisco IOS XE Software, Version 17.09.04a
...
Cisco C9300-48P (X86) processor (revision V00) with 1392640K/6147K bytes of memory.

Router uptime is 47 days, 3 hours, 22 minutes
System image file is "flash:cat9k_iosxe.17.09.04a.SPA.bin"

Sending Configuration Changes

from netmiko import ConnectHandler

cisco_device = {
    'device_type': 'cisco_ios_xe',
    'host': '192.168.1.1',
    'username': 'admin',
    'password': 'cisco123',
    'secret': 'enable_pass',
}

# Config commands as a list
config_commands = [
    'interface GigabitEthernet1/0/10',
    ' description SERVER-RACK-A',
    ' switchport mode access',
    ' switchport access vlan 100',
    ' spanning-tree portfast',
    ' no shutdown',
]

with ConnectHandler(**cisco_device) as net_connect:
    net_connect.enable()
    output = net_connect.send_config_set(config_commands)
    # Save the config
    net_connect.save_config()
    print(output)

send_config_set() automatically enters and exits config mode. save_config() runs write memory. Always verify the output — Netmiko returns everything the device printed during the session.

Looping Over Multiple Devices

from netmiko import ConnectHandler

devices = [
    {'device_type': 'cisco_ios', 'host': '10.0.0.1', 'username': 'admin', 'password': 'cisco123'},
    {'device_type': 'cisco_ios', 'host': '10.0.0.2', 'username': 'admin', 'password': 'cisco123'},
    {'device_type': 'cisco_ios', 'host': '10.0.0.3', 'username': 'admin', 'password': 'cisco123'},
]

for device in devices:
    with ConnectHandler(**device) as net_connect:
        hostname = net_connect.send_command('show run | include hostname')
        bgp_summary = net_connect.send_command('show ip bgp summary')
        print(f"\n--- {device['host']} ---")
        print(hostname)
        print(bgp_summary[:500])  # first 500 chars

This runs sequentially. For large device counts, you’ll want Nornir’s parallel execution — covered below.

Parsing Show Output with TextFSM and Genie

Raw show output is fine for humans but terrible for scripts. Enter TextFSM templates (via the ntc-templates library) and Cisco’s Genie parser — both turn CLI text into Python dictionaries.

TextFSM via Netmiko (built-in)

with ConnectHandler(**cisco_device) as net_connect:
    # use_textfsm=True parses the output automatically
    output = net_connect.send_command(
        'show ip interface brief',
        use_textfsm=True
    )

# output is now a list of dicts
for intf in output:
    print(f"{intf['intf']:20} {intf['ipaddr']:15} {intf['status']:10} {intf['proto']}")

Sample parsed output:

GigabitEthernet0/0   192.168.1.1     up         up
GigabitEthernet0/1   10.0.0.1        up         up
GigabitEthernet0/2   unassigned      admin down down
Loopback0            1.1.1.1         up         up

No regex needed. TextFSM templates exist for hundreds of show commands across Cisco, Arista, Juniper, and more. If you’re working with BGP neighbors or OSPF topology, this approach saves hours — and it pairs naturally with scripts that check BGP neighbor state, as in our BGP deep-dive.

NAPALM: Vendor-Agnostic Network Automation

NAPALM (Network Automation and Programmability Abstraction Layer with Multivendor support) sits on top of libraries like Netmiko to provide a unified API across different OS types. The same Python code works against Cisco IOS, IOS-XE, NX-OS, Juniper JunOS, and Arista EOS.

Connecting and Getting Facts

from napalm import get_network_driver

driver = get_network_driver('ios')  # 'iosxr', 'nxos', 'eos', 'junos'

device = driver(
    hostname='192.168.1.1',
    username='admin',
    password='cisco123',
    optional_args={'secret': 'enable_pass'}
)

device.open()

facts = device.get_facts()
print(f"Hostname:   {facts['hostname']}")
print(f"Model:      {facts['model']}")
print(f"OS Version: {facts['os_version']}")
print(f"Uptime:     {facts['uptime']} seconds")
print(f"Interfaces: {facts['interface_list']}")

device.close()

Retrieving BGP Neighbors

device.open()
bgp_neighbors = device.get_bgp_neighbors()

for vrf, vrf_data in bgp_neighbors.items():
    for peer_ip, peer_data in vrf_data['peers'].items():
        state = "UP" if peer_data['is_up'] else "DOWN"
        print(f"Peer {peer_ip:15} AS {peer_data['remote_as']:6} [{state}] "
              f"Prefixes: {peer_data['address_family'].get('ipv4', {}).get('received_prefixes', 0)}")

device.close()

Config Diff and Atomic Replacement

This is NAPALM’s killer feature — a dry-run diff before committing changes:

device.open()

# Load a candidate configuration (can be a full or partial config)
device.load_merge_candidate(config="""
interface Loopback100
 description MGMT-LOOPBACK
 ip address 10.100.0.1 255.255.255.255
""")

# Get the diff — shows exactly what will change
diff = device.compare_config()
print("Config diff:")
print(diff)

# Apply only if the diff looks right
if diff:
    device.commit_config()
    print("Config committed.")
else:
    device.discard_config()
    print("No changes needed.")

device.close()

On IOS-XR and JunOS, this is a true atomic commit. On IOS/IOS-XE, NAPALM handles the diff logic in software. Either way, you’re never pushing blind.

Nornir: Parallel Automation at Scale

Netmiko and NAPALM are great for single-device or sequential tasks. Nornir is what you reach for when you need to touch 200 switches simultaneously. It’s a pure Python framework — no YAML DSL, just Python functions and threading.

Inventory Setup

Nornir uses a simple directory structure for inventory. Create the following files:

# hosts.yaml
sw-core-01:
  hostname: 10.0.1.1
  groups:
    - cisco_ios
  data:
    site: HQ
    role: core

sw-access-01:
  hostname: 10.0.2.1
  groups:
    - cisco_ios
  data:
    site: HQ
    role: access

rtr-edge-01:
  hostname: 10.0.3.1
  groups:
    - cisco_iosxe
  data:
    site: HQ
    role: edge
# groups.yaml
cisco_ios:
  platform: ios
  username: admin
  password: cisco123
  connection_options:
    netmiko:
      extras:
        secret: enable_pass

cisco_iosxe:
  platform: iosxe
  username: admin
  password: cisco123
# defaults.yaml
username: admin
password: cisco123

Running Tasks in Parallel

from nornir import InitNornir
from nornir_netmiko.tasks import netmiko_send_command
from nornir_utils.plugins.functions import print_result

# Initialize with inventory
nr = InitNornir(config_file="config.yaml")

# Run 'show version' against ALL devices simultaneously (threaded)
result = nr.run(
    task=netmiko_send_command,
    command_string="show version"
)

print_result(result)

By default Nornir uses 20 threads — you can tune this in config.yaml. That means 200 devices with a 10-second response time finish in ~100 seconds total instead of ~2000 seconds sequentially.

Filtering and Targeting Specific Devices

from nornir.core.filter import F

# Only run against core routers at the HQ site
filtered = nr.filter(F(role="core") & F(site="HQ"))

result = filtered.run(
    task=netmiko_send_command,
    command_string="show ip route summary"
)

print_result(result)

Custom Task Functions

The real power is writing your own task functions:

from nornir import InitNornir
from nornir.core.task import Task, Result
from nornir_netmiko.tasks import netmiko_send_command, netmiko_send_config

def check_and_enable_ntp(task: Task) -> Result:
    """Check if NTP is configured; if not, add it."""
    output = task.run(
        task=netmiko_send_command,
        command_string="show run | include ntp server"
    ).result

    if "ntp server" in output:
        return Result(
            host=task.host,
            result=f"NTP already configured: {output.strip()}"
        )
    else:
        # Push NTP config
        task.run(
            task=netmiko_send_config,
            config_commands=[
                "ntp server 10.0.0.100",
                "ntp server 10.0.0.101 prefer"
            ]
        )
        return Result(
            host=task.host,
            result="NTP servers added.",
            changed=True
        )

nr = InitNornir(config_file="config.yaml")
result = nr.run(task=check_and_enable_ntp)
print_result(result)

This is idempotent — it checks state before making changes. Run it daily as a compliance check and you’ll never have a device missing NTP again.

Real-World Use Cases

Automated Pre/Post Change Snapshots

import json
from datetime import datetime
from nornir import InitNornir
from nornir_netmiko.tasks import netmiko_send_command

SNAPSHOT_COMMANDS = [
    "show ip route summary",
    "show ip bgp summary",
    "show interfaces status",
    "show spanning-tree summary",
    "show version",
]

def take_snapshot(task: Task):
    results = {}
    for cmd in SNAPSHOT_COMMANDS:
        output = task.run(
            task=netmiko_send_command,
            command_string=cmd
        ).result
        results[cmd] = output

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    filename = f"/tmp/snapshots/{task.host}_{timestamp}.json"
    with open(filename, 'w') as f:
        json.dump(results, f, indent=2)

    return Result(host=task.host, result=f"Snapshot saved: {filename}")

nr = InitNornir(config_file="config.yaml")
nr.run(task=take_snapshot)

Run this before and after maintenance windows. Diff the JSON files to confirm exactly what changed (or didn’t). No more arguing with the change control board about impact.

VLAN Audit Across the Entire Network

from nornir import InitNornir
from nornir_netmiko.tasks import netmiko_send_command
from nornir.core.filter import F

def get_vlan_list(task: Task):
    output = task.run(
        task=netmiko_send_command,
        command_string="show vlan brief",
        use_textfsm=True
    ).result
    return Result(host=task.host, result=output)

nr = InitNornir(config_file="config.yaml")
# Only switches
switches = nr.filter(F(role="access") | F(role="distribution"))
result = switches.run(task=get_vlan_list)

# Aggregate: find VLANs present on some switches but not others
vlan_map = {}
for host, r in result.items():
    if not r[0].failed:
        vlan_map[str(host)] = [v['vlan_id'] for v in r[0].result]

all_vlans = set(v for vlans in vlan_map.values() for v in vlans)
print("VLAN consistency check:")
for vlan in sorted(all_vlans):
    missing_on = [h for h, vlans in vlan_map.items() if vlan not in vlans]
    if missing_on:
        print(f"  VLAN {vlan}: MISSING on {missing_on}")
    else:
        print(f"  VLAN {vlan}: OK (all switches)")

Error Handling and Retry Logic

Production networks are messy. Devices go into high CPU during your automation window, SSH connections time out, authentication fails on that one switch nobody updated the password on. Robust error handling isn’t optional:

from netmiko import ConnectHandler, NetmikoTimeoutException, NetmikoAuthenticationException
import logging

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s %(levelname)s %(message)s',
    filename='netauto.log'
)
logger = logging.getLogger(__name__)

def safe_connect_and_run(device_dict, command):
    """Connect with error handling and return output or None."""
    host = device_dict.get('host', 'unknown')
    try:
        with ConnectHandler(**device_dict) as conn:
            conn.enable()
            output = conn.send_command(command, read_timeout=60)
            logger.info(f"[{host}] Command succeeded: {command}")
            return output

    except NetmikoAuthenticationException:
        logger.error(f"[{host}] Authentication FAILED — check credentials")
        return None

    except NetmikoTimeoutException:
        logger.error(f"[{host}] Connection TIMED OUT — device unreachable or overloaded")
        return None

    except Exception as e:
        logger.error(f"[{host}] Unexpected error: {e}")
        return None

In Nornir, failed tasks are collected separately — you can inspect them after the run and generate a report of which devices had issues and why, without the whole job aborting.

Credentials Management

Never hardcode passwords in scripts. Use environment variables or a secrets manager:

import os
from netmiko import ConnectHandler

device = {
    'device_type': 'cisco_ios_xe',
    'host': '192.168.1.1',
    'username': os.environ['NET_USERNAME'],
    'password': os.environ['NET_PASSWORD'],
    'secret': os.environ['NET_ENABLE'],
}

For production, look at HashiCorp Vault, CyberArk, or even a simple encrypted .env file with python-dotenv. The Cisco IOS/IOS-XE hardening considerations for management plane security are worth keeping in mind here — securing your automation infrastructure is as important as securing the devices themselves.

Where to Go From Here

Once you’re comfortable with these three libraries, the natural next steps are:

  • Cisco Genie / pyATS: Cisco’s own parsing and testing framework — extremely powerful for structured output from IOS-XE/IOS-XR/NX-OS
  • RESTCONF/NETCONF: Model-driven programmability on modern IOS-XE devices; no SSH screen-scraping required
  • Nornir + NAPALM plugin: Combine Nornir’s parallel execution with NAPALM’s vendor-agnostic API for the best of both worlds
  • CI/CD for network config: Store configs in Git, validate with pyATS, deploy with Nornir — a full GitOps workflow for your network

If you’re working in a mixed-OS environment and need to understand the IOS platform differences your scripts will encounter, our Cisco IOS vs IOS-XE vs IOS-XR breakdown is essential reading before you start coding against IOS-XR devices specifically — the connection handling and config commit model are meaningfully different.

The scripts in this guide are production-ready starting points. Adapt the inventory structure to match your environment, add proper logging with Python’s logging module, and consider wrapping everything in a simple CLI with argparse or click so other engineers on your team can use your tools without touching the code. That’s how you turn a personal productivity hack into a team automation platform.

Enjoying this post?

Get more guides like this delivered straight to your inbox. No spam, just tech and trails.