Back to Blog
โ˜…โ˜…โ˜†Intermediate๐Ÿค– Network Automation
AutomationPythonNetmikoNAPALMDevOpsCisco

Network Automation with Python: Netmiko, NAPALM, and Config Auditing

March 10, 2026ยท7 min read

Overview

Manual CLI changes across hundreds of devices are slow, error-prone, and unauditable. Python-based network automation eliminates the repetitive work, enforces consistency, and gives you a defensible record of every change. This guide covers the two most practical automation libraries โ€” Netmiko for SSH-based CLI interaction and NAPALM for structured multi-vendor config management โ€” along with real-world patterns for inventory management, config auditing, and compliance reporting.


// Python Automation Workflow โ€” Inventory to Compliance Report
Inventory YAML / CSV hosts.yml Python Script Netmiko / NAPALM ThreadPool Router-1 SSH / API Switch-1 SSH / API + N devices Output Compliance report Config backups Change log Parallel execution โ€” 200 devices in seconds with ThreadPoolExecutor

Part 1 โ€” Netmiko: SSH CLI Automation

Netmiko abstracts SSH connections across 80+ vendor platforms, handling prompt detection, paging, and error handling automatically.

Installation

pip install netmiko
pip install netmiko[textfsm]   # adds structured output parsing

Basic Connection and Commands

from netmiko import ConnectHandler

device = {
    'device_type': 'cisco_ios',
    'host':        '192.168.1.1',
    'username':    'admin',
    'password':    'SecurePass!',
    'secret':      'EnablePass!',
}

with ConnectHandler(**device) as net_connect:
    net_connect.enable()

    # Show command
    output = net_connect.send_command('show ip interface brief')
    print(output)

    # Push config
    config_commands = [
        'interface Loopback0',
        'description AUTOMATION-MANAGED',
        'ip address 10.255.255.1 255.255.255.255',
    ]
    net_connect.send_config_set(config_commands)
    net_connect.save_config()

Structured Output with TextFSM

with ConnectHandler(**device) as net_connect:
    # Returns list of dicts instead of raw text
    bgp_summary = net_connect.send_command(
        'show bgp summary',
        use_textfsm=True
    )
    for neighbor in bgp_summary:
        print(f"Neighbor: {neighbor['bgp_neigh']}  State: {neighbor['state_pfxrcd']}")

Parallel Execution Across a Fleet

from netmiko import ConnectHandler
from concurrent.futures import ThreadPoolExecutor, as_completed
import yaml

def get_version(device):
    try:
        with ConnectHandler(**device) as conn:
            output = conn.send_command('show version', use_textfsm=True)
            return {'host': device['host'], 'version': output[0]['version']}
    except Exception as e:
        return {'host': device['host'], 'error': str(e)}

with open('inventory.yml') as f:
    devices = yaml.safe_load(f)['devices']

results = []
with ThreadPoolExecutor(max_workers=20) as executor:
    futures = {executor.submit(get_version, d): d for d in devices}
    for future in as_completed(futures):
        results.append(future.result())

for r in sorted(results, key=lambda x: x['host']):
    print(f"{r['host']:20} {r.get('version', r.get('error'))}")

Part 2 โ€” NAPALM: Multi-Vendor Structured Config

NAPALM provides a unified API across IOS, IOS-XR, NX-OS, JunOS, and EOS โ€” returning structured Python objects instead of raw CLI text.

from napalm import get_network_driver

driver = get_network_driver('ios')
device = driver(hostname='192.168.1.1', username='admin', password='SecurePass!')
device.open()

# Structured facts โ€” identical API across all vendors
facts = device.get_facts()
print(f"Hostname: {facts['hostname']}  Model: {facts['model']}  OS: {facts['os_version']}")

# BGP neighbors with structured data
bgp = device.get_bgp_neighbors()
for vrf, data in bgp.items():
    for peer, info in data['peers'].items():
        prefixes = info['address_family']['ipv4']['received_prefixes']
        print(f"VRF {vrf} | Peer {peer} | Up: {info['is_up']} | Prefixes: {prefixes}")

# Detect interfaces that are admin-up but link-down
interfaces = device.get_interfaces()
for name, iface in interfaces.items():
    if iface['is_enabled'] and not iface['is_up']:
        print(f"ALERT: {name} is admin-up but link-down")

device.close()

Config Diff and Atomic Replace

device.open()
device.load_replace_candidate(filename='desired_config.txt')

diff = device.compare_config()
print(diff)

if diff:
    confirm = input("Commit? [y/N]: ")
    if confirm.lower() == 'y':
        device.commit_config()
    else:
        device.discard_config()

device.close()

$ python3 get_versions.py โ€” ThreadPoolExecutor max_workers=20
Loading inventory.yml ... 6 devices Spawning ThreadPoolExecutor(max_workers=20) 192.168.1.1 15.2(4)M7 cisco_ios 192.168.1.2 15.2(4)M7 cisco_ios 192.168.1.3 15.6(3)M2 cisco_ios 192.168.1.4 16.9.6 cisco_ios 192.168.1.5 SSH timeout cisco_ios 192.168.1.6 15.2(4)M7 cisco_ios Completed: 5 success 1 error โ€” elapsed: 4.2s

Part 3 โ€” Config Compliance Auditing

from netmiko import ConnectHandler
import yaml, json
from datetime import datetime

REQUIRED_CONFIG = [
    'service password-encryption',
    'no ip http server',
    'logging buffered 65536',
    'ip ssh version 2',
    'spanning-tree portfast bpduguard default',
]

BANNED_CONFIG = [
    'ip http server',
    'snmp-server community public',
    'no service password-encryption',
]

def audit_device(device):
    result = {'host': device['host'], 'missing': [], 'violations': [], 'error': None}
    try:
        with ConnectHandler(**device) as conn:
            conn.enable()
            running = conn.send_command('show running-config')
        for r in REQUIRED_CONFIG:
            if r not in running:
                result['missing'].append(r)
        for b in BANNED_CONFIG:
            if b in running:
                result['violations'].append(b)
    except Exception as e:
        result['error'] = str(e)
    return result

with open('inventory.yml') as f:
    devices = yaml.safe_load(f)['devices']

results = [audit_device(d) for d in devices]
report = {'timestamp': datetime.now().isoformat(), 'results': results}

with open('compliance_report.json', 'w') as f:
    json.dump(report, f, indent=2)

for r in results:
    status = "PASS" if not r['missing'] and not r['violations'] else "FAIL"
    print(f"{r['host']:20} [{status}]  missing={len(r['missing'])}  violations={len(r['violations'])}")

Quick Reference โ€” Netmiko Device Types

PlatformNetmiko device_typeNAPALM driver
Cisco IOS / IOS-XEcisco_iosios
Cisco IOS-XRcisco_xriosxr
Cisco NX-OScisco_nxosnxos
Cisco ASAcisco_asaโ€”
Palo Alto PAN-OSpaloalto_panosโ€”
Juniper JunOSjuniper_junosjunos
Arista EOSarista_eoseos
F5 BIG-IPf5_tmshโ€”

Automation Best Practices

  • Always test scripts against a lab or staging device before running on production
  • Cap max_workers at 20โ€“30 โ€” too many parallel SSH sessions can overwhelm device control planes
  • Wrap every device connection in try/except โ€” one unreachable host should not abort the entire job
  • Log all config changes with timestamp, operator name, and device hostname
  • Store configs in Git and run daily backups โ€” diff against previous commits to detect configuration drift
  • Never store credentials in scripts or YAML files โ€” use environment variables or a secrets manager such as HashiCorp Vault
  • Use send_command_timing() instead of send_command() for interactive prompts that lack a fixed ending pattern