Python for Network Engineers — Part 5: Netmiko — SSH Automation Across Vendors

The first four posts gave you Python. This one gives you network devices.

Netmiko is a Python library built by Kirk Byers that extends the Paramiko SSH library with network device awareness — prompt detection, enable mode handling, paging suppression, and support for dozens of vendor platforms out of the box. It turns an SSH session into function calls.

Before Netmiko, automating network devices over SSH meant wrestling with Paramiko directly: waiting for prompts manually, handling interactive mode, managing terminal width. Netmiko abstracts all of that. You tell it what platform you’re connecting to, give it credentials, and call send_command(). It handles the rest.

This post uses a Containerlab lab for the examples. We’ll connect to real (virtual) devices, collect data, and push configuration changes — exactly the workflow you’d use against production hardware.


Setting Up the Lab

Install Containerlab

Containerlab runs on Linux. If you’re on Windows or macOS, run it inside a Linux VM or WSL2 (Windows Subsystem for Linux). The Containerlab documentation covers installation well, but the short version:

# Linux / WSL2
curl -sL https://containerlab.dev/setup | sudo bash -s "all"

Verify:

sudo containerlab version

You’ll also need Docker installed — Containerlab manages containers through it:

# Verify Docker is running
docker ps

Get the Arista cEOS Image

This lab uses Arista cEOS — a containerised version of EOS that runs on standard x86 hardware. It’s free but requires a (free) Arista account.

  1. Register at arista.com
  2. Navigate to Software Downloads → EOS → cEOS-lab
  3. Download the latest cEOS-lab .tar.xz file (look for cEOS-lab-x.x.x.tar.xz)
  4. Import it into Docker:
docker import cEOS-lab-4.32.0F.tar.xz ceos:4.32.0F
docker tag ceos:4.32.0F ceos:latest

Verify the import:

docker images | grep ceos

Why cEOS? It’s freely available, behaves almost identically to physical Arista hardware, and Netmiko’s arista_eos platform is extremely well tested against it. When your lab script works against cEOS, it’ll work against real Arista switches. The platform mapping table later in this post shows how the same code maps to Cisco IOS, IOS-XE, Fortinet, and others.

The Topology

Create a project folder and the topology file:

mkdir ~/netauto-lab
cd ~/netauto-lab

Create topology.yaml:

name: netauto-lab

mgmt:
  network: mgmt
  ipv4-subnet: 192.168.100.0/24

topology:
  nodes:
    spine-01:
      kind: ceos
      image: ceos:latest
      mgmt-ipv4: 192.168.100.10
      startup-config: configs/spine-01.cfg

    leaf-01:
      kind: ceos
      image: ceos:latest
      mgmt-ipv4: 192.168.100.11
      startup-config: configs/leaf-01.cfg

    leaf-02:
      kind: ceos
      image: ceos:latest
      mgmt-ipv4: 192.168.100.12
      startup-config: configs/leaf-02.cfg

  links:
    - endpoints: ["spine-01:eth1", "leaf-01:eth1"]
    - endpoints: ["spine-01:eth2", "leaf-02:eth1"]
    - endpoints: ["leaf-01:eth2", "leaf-02:eth2"]

Create the configs/ directory and a startup config for each node. The startup configs set hostnames and enable SSH with a known username and password — we’ll use admin / netauto throughout:

mkdir configs

configs/spine-01.cfg:

hostname spine-01
!
username admin privilege 15 role network-admin secret netauto
!
management api http-commands
   no shutdown
!

configs/leaf-01.cfg:

hostname leaf-01
!
username admin privilege 15 role network-admin secret netauto
!
management api http-commands
   no shutdown
!

configs/leaf-02.cfg:

hostname leaf-02
!
username admin privilege 15 role network-admin secret netauto
!
management api http-commands
   no shutdown
!

Start the lab:

sudo containerlab deploy -t topology.yaml

Containerlab pulls and starts the containers, applies the startup configs, and sets up the management network. First run takes a couple of minutes as cEOS initialises. You’ll see output confirming each node is up.

Verify you can reach the devices:

ping -c 2 192.168.100.10   # spine-01
ping -c 2 192.168.100.11   # leaf-01
ping -c 2 192.168.100.12   # leaf-02

SSH in manually to confirm credentials work before running any scripts:

ssh [email protected]
# Password: netauto
# You should see: spine-01>

Installing Netmiko

Back in your Python project folder — not the lab folder:

mkdir ~/netauto-scripts
cd ~/netauto-scripts
uv venv
source .venv/bin/activate
uv pip install netmiko python-dotenv
git init

python-dotenv loads credentials from a .env file — we’ll use that to keep passwords out of source code.


Handling Credentials Securely

Before writing a single Netmiko line, establish the credentials pattern you’ll use for this entire series.

Create .env in your project folder:

DEVICE_USERNAME=admin
DEVICE_PASSWORD=netauto

Add .env to .gitignore immediately:

echo ".env" >> .gitignore
echo ".venv/" >> .gitignore
git add .gitignore
git commit -m "Initial commit — gitignore set up"

Load credentials in your scripts with python-dotenv:

import os
from dotenv import load_dotenv

load_dotenv()   # reads .env into environment variables

USERNAME = os.environ["DEVICE_USERNAME"]
PASSWORD = os.environ["DEVICE_PASSWORD"]

os.environ["KEY"] raises a KeyError if the variable is missing — better than silently connecting with empty credentials. This pattern works the same way in CI/CD pipelines and on remote servers, where you set environment variables directly rather than using a .env file.


Your First Connection

Create first_connection.py:

import os
from dotenv import load_dotenv
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException

load_dotenv()

device = {
    "device_type": "arista_eos",
    "host": "192.168.100.10",
    "username": os.environ["DEVICE_USERNAME"],
    "password": os.environ["DEVICE_PASSWORD"],
}

try:
    connection = ConnectHandler(**device)
    print(f"Connected to {connection.find_prompt()}")
    connection.disconnect()
except NetmikoTimeoutException:
    print(f"Timed out connecting to {device['host']}")
except NetmikoAuthenticationException:
    print(f"Authentication failed for {device['host']}")

Run it:

python3 first_connection.py
# Connected to spine-01>

ConnectHandler(**device) unpacks the dict as keyword arguments — equivalent to ConnectHandler(device_type="arista_eos", host="192.168.100.10", ...). The ** unpack syntax from Part 4 pays off here: your device inventory is a list of dicts, and you pass each one to ConnectHandler with a single **.

find_prompt() returns the device’s current prompt — a quick sanity check that you’re connected and on the right device.

The Context Manager Pattern

Always use Netmiko as a context manager with with. It guarantees the connection is closed even if an exception occurs:

try:
    with ConnectHandler(**device) as conn:
        prompt = conn.find_prompt()
        print(f"Connected: {prompt}")
    # Connection is closed automatically here
except NetmikoTimeoutException:
    print(f"Timeout: {device['host']}")
except NetmikoAuthenticationException:
    print(f"Auth failed: {device['host']}")

Without with, a script that crashes mid-execution leaves SSH sessions hanging on the device. On a device with a low session limit, this causes your next connection attempt to fail. The context manager avoids the problem entirely.


Sending Commands

send_command() sends a command and returns the output as a string:

with ConnectHandler(**device) as conn:
    output = conn.send_command("show version")
    print(output)

Netmiko handles paging automatically — it sends the equivalent of terminal length 0 on Cisco, or uses the platform’s equivalent. You get the complete output in one string.

send_command vs send_command_timing

There are two variants. Understanding the difference saves debugging time:

send_command() waits until it detects the device prompt again before returning. This is reliable for commands that produce a fixed, predictable output — show commands, interface status, routing tables.

send_command_timing() waits a fixed amount of time and returns whatever was in the buffer. Use it for interactive commands that don’t return to the normal prompt, like copy running-config startup-config on some Cisco platforms, or commands that prompt for confirmation:

# send_command() — safe for show commands
version_output = conn.send_command("show version")

# send_command_timing() — for commands that prompt or don't return to prompt
conn.send_command_timing("copy running-config startup-config")

When in doubt, use send_command(). Only reach for send_command_timing() when send_command() hangs.

Useful send_command Options

# Increase timeout for slow commands (default is 100 seconds)
output = conn.send_command("show ip bgp", read_timeout=300)

# Strip ANSI escape codes (Arista/some Linux devices include them)
output = conn.send_command("show interfaces", strip_command=False)

# Use TextFSM to parse output automatically (covered in Part 6)
parsed = conn.send_command("show ip interface brief", use_textfsm=True)

Pushing Configuration

send_config_set() takes a list of configuration commands, enters config mode, sends them, and exits config mode:

config_commands = [
    "interface Ethernet1",
    "description Link to spine-01",
    "no shutdown",
]

with ConnectHandler(**device) as conn:
    output = conn.send_config_set(config_commands)
    print(output)
    conn.save_config()   # writes running-config to startup-config

send_config_set() returns the full terminal exchange — everything the device sent back including prompts and command echoes. This is useful for logging but not for parsing; always use send_command() for output you intend to process.

save_config() handles the platform-specific save command — write memory on Cisco IOS, copy running-config startup-config on others. Netmiko picks the right one for your platform.

Sending Config from a File

For larger config blocks, send_config_from_file() reads commands from a text file — one command per line:

with ConnectHandler(**device) as conn:
    output = conn.send_config_from_file("changes/leaf-01-vlans.txt")
    conn.save_config()

This keeps your scripts clean and lets you version the config changes separately from the automation code.


Platform Mapping

The device_type string is what tells Netmiko how to handle each platform’s prompts, paging, config mode, and save commands. Here are the ones you’ll use most:

Platformdevice_type
Arista EOSarista_eos
Cisco IOScisco_ios
Cisco IOS-XEcisco_xe
Cisco IOS-XRcisco_xr
Cisco NX-OScisco_nxos
Cisco ASAcisco_asa
Fortinet FortiOSfortinet
Juniper JunOS (CLI)juniper_junos
Palo Alto PAN-OSpaloalto_panos
VyOSvyos
Linux (generic SSH)linux

Beyond device_type, the rest of the ConnectHandler arguments stay the same across all platforms. This is Netmiko’s core value — your code doesn’t change when you move from Cisco to Arista to Fortinet; only the platform string does.

For Cisco IOS-XE specifically, there are two things to know. First, if the account doesn’t have privilege 15 from login, you’ll need to call conn.enable() after connecting:

with ConnectHandler(**device) as conn:
    conn.enable()   # enters enable mode
    output = conn.send_command("show running-config")

Second, Cisco devices are more sensitive to timing than cEOS. If send_command() returns incomplete output, increase global_delay_factor:

device = {
    "device_type": "cisco_xe",
    "host": "10.0.0.1",
    "username": USERNAME,
    "password": PASSWORD,
    "global_delay_factor": 2,   # doubles all internal timeouts
}

Real Hardware Notes

Virtual devices behave differently from physical hardware in ways that matter for automation:

Response timing. Physical hardware — especially older Cisco kit — is slower to respond than a container running on your laptop. Scripts that work perfectly against cEOS may hang or return partial output against an ASR or older ISR. Increase read_timeout for slow devices and always test against representative hardware before running against production.

SSH ciphers and key exchange. Older IOS versions support only deprecated ciphers and key exchange algorithms that modern SSH clients reject by default. If you see SSH negotiation failed against old gear, add conn_timeout and consider SSH options:

device = {
    "device_type": "cisco_ios",
    "host": "10.0.0.1",
    "username": USERNAME,
    "password": PASSWORD,
    "conn_timeout": 20,
}

For very old devices that won’t negotiate with modern algorithms, you may need to configure your SSH client (~/.ssh/config) to permit legacy ciphers. Netmiko passes through to Paramiko’s underlying SSH negotiation.

Terminal width. Physical devices remember your terminal width from the last interactive session. A 40-character wide SSH session will wrap show interfaces output mid-line. Netmiko sends terminal width 512 automatically on supported platforms, but confirm it’s working on first use against new hardware.

Enable mode. Many production Cisco devices are configured to require enable even for privileged accounts. If send_command("show running-config") returns % Invalid input detected or the output is truncated, call conn.enable() first and pass secret in the device dict:

device = {
    "device_type": "cisco_ios",
    ...
    "secret": os.environ["ENABLE_SECRET"],
}

Exception Handling with Netmiko

Three exceptions cover the majority of Netmiko failures:

from netmiko.exceptions import (
    NetmikoTimeoutException,
    NetmikoAuthenticationException,
    NetmikoBaseException,
)
ExceptionCause
NetmikoTimeoutExceptionDevice unreachable, SSH port closed, device too slow
NetmikoAuthenticationExceptionWrong credentials, account locked
NetmikoBaseExceptionCatch-all for other Netmiko errors

In a multi-device script, catch per-device and continue — don’t let one unreachable device stop the rest:

results = {}
failures = []

for device in inventory:
    try:
        with ConnectHandler(**device) as conn:
            results[device["host"]] = conn.send_command("show version")
    except NetmikoTimeoutException:
        failures.append((device["host"], "timeout"))
    except NetmikoAuthenticationException:
        failures.append((device["host"], "auth failed"))
    except NetmikoBaseException as e:
        failures.append((device["host"], str(e)))

Capstone Script: Multi-Device Inventory Collector

Here’s the payoff. A script that connects to all three lab devices, collects show version, extracts the EOS version and model using the regex skills from Part 4, and produces a clean inventory report.

Project structure:

netauto-scripts/
├── .env
├── .gitignore
├── inventory.py      # device list
├── parsers.py        # output parsers
└── collect.py        # main script

inventory.py:

"""Device inventory for the netauto-lab."""

import os
from dotenv import load_dotenv

load_dotenv()

DEVICES = [
    {
        "device_type": "arista_eos",
        "host": "192.168.100.10",
        "username": os.environ["DEVICE_USERNAME"],
        "password": os.environ["DEVICE_PASSWORD"],
        "name": "spine-01",
    },
    {
        "device_type": "arista_eos",
        "host": "192.168.100.11",
        "username": os.environ["DEVICE_USERNAME"],
        "password": os.environ["DEVICE_PASSWORD"],
        "name": "leaf-01",
    },
    {
        "device_type": "arista_eos",
        "host": "192.168.100.12",
        "username": os.environ["DEVICE_USERNAME"],
        "password": os.environ["DEVICE_PASSWORD"],
        "name": "leaf-02",
    },
]

parsers.py:

"""Parsers for Arista EOS show command output."""

import re


def parse_eos_version(output: str) -> dict:
    """Extract key fields from 'show version' on Arista EOS.

    Returns a dict with: hostname, model, eos_version, serial, uptime.
    Any field that can't be extracted is set to 'unknown'.
    """
    fields = {
        "hostname": "unknown",
        "model": "unknown",
        "eos_version": "unknown",
        "serial": "unknown",
        "uptime": "unknown",
    }

    patterns = {
        "hostname":    r"^Hostname:\s+(\S+)",
        "model":       r"^Model:\s+(.+)",
        "eos_version": r"^Software image version:\s+(\S+)",
        "serial":      r"^Serial number:\s+(\S+)",
        "uptime":      r"^Uptime:\s+(.+)",
    }

    for field, pattern in patterns.items():
        match = re.search(pattern, output, re.MULTILINE)
        if match:
            fields[field] = match.group(1).strip()

    return fields

collect.py:

"""Collect show version from all lab devices and print an inventory report."""

import sys
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException

from inventory import DEVICES
from parsers import parse_eos_version


def collect_version(device: dict) -> tuple[dict | None, str | None]:
    """Connect to a device and return parsed version data.

    Returns (parsed_data, None) on success, or (None, error_message) on failure.
    """
    name = device.get("name", device["host"])
    # ConnectHandler doesn't accept a 'name' key — remove before connecting
    conn_params = {k: v for k, v in device.items() if k != "name"}

    try:
        with ConnectHandler(**conn_params) as conn:
            output = conn.send_command("show version")
            data = parse_eos_version(output)
            data["host"] = device["host"]
            return data, None
    except NetmikoTimeoutException:
        return None, f"timeout connecting to {device['host']}"
    except NetmikoAuthenticationException:
        return None, f"authentication failed for {device['host']}"


def main():
    print(f"\nCollecting inventory from {len(DEVICES)} device(s)...\n")

    results = []
    failures = []

    for device in DEVICES:
        name = device.get("name", device["host"])
        print(f"  Connecting to {name} ({device['host']})... ", end="", flush=True)
        data, error = collect_version(device)
        if data:
            data["name"] = name
            results.append(data)
            print("OK")
        else:
            failures.append((name, error))
            print(f"FAILED — {error}")

    # Print the report
    if results:
        col = "{:<12} {:<16} {:<28} {:<16} {}"
        header = col.format("Name", "Host", "Model", "EOS Version", "Uptime")
        print(f"\n{header}")
        print("─" * len(header))
        for r in results:
            print(col.format(
                r["name"],
                r["host"],
                r["model"],
                r["eos_version"],
                r["uptime"],
            ))

    if failures:
        print(f"\nFailed devices ({len(failures)}):")
        for name, reason in failures:
            print(f"  {name}: {reason}")

    print(f"\nDone: {len(results)} collected, {len(failures)} failed.")
    if failures:
        sys.exit(1)


if __name__ == "__main__":
    main()

Run it with the lab up:

python3 collect.py

Expected output:

Collecting inventory from 3 device(s)...

  Connecting to spine-01 (192.168.100.10)... OK
  Connecting to leaf-01 (192.168.100.11)... OK
  Connecting to leaf-02 (192.168.100.12)... OK

Name         Host             Model                        EOS Version      Uptime
─────────────────────────────────────────────────────────────────────────────────
spine-01     192.168.100.10   vEOS-lab                     4.32.0F          0:08:14
leaf-01      192.168.100.11   vEOS-lab                     4.32.0F          0:08:11
leaf-02      192.168.100.12   vEOS-lab                     4.32.0F          0:08:09

Done: 3 collected, 0 failed.

When you’re finished with the lab:

sudo containerlab destroy -t topology.yaml

What’s Coming in Part 6

Part 6 tackles output parsing properly. send_command() returns a string — useful, but a string isn’t structured data you can filter, sort, or compare. We’ll cover TextFSM with NTC-templates (the most direct path from raw CLI to Python dicts), look at where Genie parsers fit in, and explore where AI-assisted parsing handles the cases that templates don’t cover.


The Containerlab topology for this post is in topology.yaml above. Requires Docker and a free Arista cEOS image (registration at arista.com). For Cisco IOS-XE, replace kind: ceos with kind: vr-csr and update device_type to cisco_xe — the Python code is otherwise identical.