Netmiko in Practice: From a Show-Command Script to a Repeatable Audit Tool

Where this starts

A few years ago I put a small repository on GitHub called ciscocmd1. It does one thing: take a list of commands in a text file and a list of devices in a JSON file, and run those commands across every device using Netmiko. The whole thing is a single script, cmdrunner.py, plus a small helper module mytools.py, plus four input files (router_commands.txt, routers.json, switch_commands.txt, switches.json). You invoke it like this:

python cmdrunner.py router_commands.txt routers.json

It is not sophisticated. It does the job. The reason it has lasted is the shape of the inputs — separate the list of devices from the list of commands, and you can keep one script that handles routers, switches, and anything else that speaks Cisco-flavoured CLI, by swapping the inventory and command files. That separation is the part worth keeping.

This post is for engineers who already write some Python and are tired of typing the same show commands into 30 routers. It walks through the Netmiko fundamentals using my repo’s pattern as a starting point, then extends it into something you would actually want to run against production: structured output, parallel execution, secure credential handling, and a careful dry-run pattern for the day you decide to use it for config changes rather than just audits.

Netmiko fundamentals

Netmiko is the SSH library that network automation actually runs on. It is a wrapper around Paramiko that knows about network device prompts, mode changes (privileged exec, configuration), pagination (--More--), and the dozens of small differences between Cisco IOS, IOS-XR, NX-OS, Junos, Arista, and so on.

The minimum useful program:

from netmiko import ConnectHandler

device = {
    "device_type": "cisco_ios",
    "host": "10.0.0.1",
    "username": "alice",
    "password": "redacted",
    "secret": "redacted",
}

with ConnectHandler(**device) as conn:
    conn.enable()
    output = conn.send_command("show ip interface brief")
    print(output)

The device_type matters. It tells Netmiko which platform-specific quirks to apply. Common ones: cisco_ios, cisco_xe, cisco_xr, cisco_nxos, cisco_asa, arista_eos, juniper_junos, linux, fortinet. The full list is in the Netmiko docs and is the first thing to confirm when something behaves oddly.

send_command is for show commands. It waits for the prompt to come back. send_command_timing is the looser version that uses delay-based detection — slower and less reliable, only reach for it when prompt detection fails. send_config_set takes a list of config commands and runs them in configuration mode.

Two patterns that save time later:

# Read multiple commands and collect output as a dict
commands = ["show version", "show ip int brief", "show running-config | include hostname"]
results = {cmd: conn.send_command(cmd) for cmd in commands}

# Apply config from a list with idempotency intent
config_lines = [
    "interface GigabitEthernet0/1",
    " description Uplink to core",
    " no shutdown",
]
output = conn.send_config_set(config_lines)

send_config_set automatically enters config mode and exits when done. Whatever the device sees in show running-config afterwards is the actual state, regardless of what your script intended. Always read it back if you care.

The pattern from ciscocmd1

The shape of cmdrunner.py is essentially this:

import json
import sys
from netmiko import ConnectHandler

def main(commands_file, inventory_file):
    with open(commands_file) as f:
        commands = [line.strip() for line in f if line.strip()]
    with open(inventory_file) as f:
        devices = json.load(f)

    for dev in devices:
        print(f"--- {dev['host']} ---")
        with ConnectHandler(**dev) as conn:
            conn.enable()
            for cmd in commands:
                print(f"\n>>> {cmd}")
                print(conn.send_command(cmd))

if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

The good thing about this shape is what it does not do. It does not hard-code a command list. It does not hard-code a device list. It does not embed credentials. The two text/JSON files are the inputs, and the Python code is the orchestrator. That is the right separation, and it is why the repo has held up — the same script that runs show running-config against routers can run show vlan brief against switches, with no code changes.

Everything below is “what to add to that pattern” rather than “throw it away and start again”.

Adding structured output

The first thing audit work needs is structured output. Pretty-printed CLI is fine for a human watching one device; it is useless for diffing across a fleet of fifty. Netmiko ships with TextFSM template integration via NTC Templates, which converts most common Cisco show commands into lists of dictionaries.

Install the templates and turn it on:

pip install netmiko ntc-templates
output = conn.send_command("show ip interface brief", use_textfsm=True)
# Now output is a list of dicts:
# [{"intf": "GigabitEthernet0/0", "ipaddr": "10.0.0.1", "status": "up", "proto": "up"},
#  {"intf": "GigabitEthernet0/1", "ipaddr": "unassigned", "status": "down", "proto": "down"}, ...]

This single change makes the difference between an audit script that produces a pile of text and one that produces data. With dicts in hand, the rest of the pipeline is trivial:

import csv

with open("interface_state.csv", "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=["host", "intf", "ipaddr", "status", "proto"])
    writer.writeheader()
    for dev in devices:
        with ConnectHandler(**dev) as conn:
            conn.enable()
            rows = conn.send_command("show ip interface brief", use_textfsm=True)
            for row in rows:
                writer.writerow({"host": dev["host"], **row})

That CSV opens cleanly in Excel, feeds into pandas, joins to a CMDB, and is the kind of artefact people actually act on. Native CLI output is not.

When TextFSM does not have a template for the command you need — and it will not, eventually — you have three choices: write your own template, use Genie/pyATS for richer parsing, or use TTP for ad-hoc patterns. There is a whole post on this coming next in the series, so I will not duplicate it here.

Concurrency

The single biggest improvement to a cmdrunner.py-style script is making it run devices in parallel. Sequential SSH against fifty boxes takes long enough that you start watching the output rather than doing other work. Parallel against fifty boxes is bounded by the slowest one.

The right tool is concurrent.futures. Stick to threads — Netmiko sessions are I/O-bound, GIL is not the bottleneck:

from concurrent.futures import ThreadPoolExecutor, as_completed

def collect(dev, commands):
    try:
        with ConnectHandler(**dev) as conn:
            conn.enable()
            return dev["host"], {
                cmd: conn.send_command(cmd, use_textfsm=True)
                for cmd in commands
            }, None
    except Exception as exc:
        return dev["host"], None, str(exc)

with ThreadPoolExecutor(max_workers=20) as pool:
    futures = [pool.submit(collect, dev, commands) for dev in devices]
    for fut in as_completed(futures):
        host, data, err = fut.result()
        if err:
            print(f"[FAIL] {host}: {err}")
        else:
            print(f"[OK]   {host}")
            # ... handle data

A few practical notes. Twenty workers is a sensible starting point — enough to be fast, not so many that you DDOS the AAA server with a burst of authentications. If your devices are behind a TACACS box, talk to whoever runs it before you go higher. Always wrap the per-device work in a try/except so a single bad device does not kill the run.

The as_completed pattern matters because it gives you results as they come in, which means progress feedback and the option to abort early if something is going wrong. Calling pool.map collects everything before returning, which feels easier to write and is harder to live with.

Credentials

Hard-coding passwords is what everyone does first and what nobody should ever ship. Stop that early. The defensive pattern, in increasing order of seriousness:

# Minimum acceptable: prompt at runtime
import getpass
device["password"] = getpass.getpass("Device password: ")
device["secret"] = getpass.getpass("Enable secret: ")
# Better: environment variables, set by a wrapper that decrypts from a vault
import os
device["password"] = os.environ["NETOPS_PASSWORD"]
device["secret"] = os.environ.get("NETOPS_ENABLE", os.environ["NETOPS_PASSWORD"])
# Production: pull from a real secret store, never written to disk
import hvac  # HashiCorp Vault client
client = hvac.Client(url="https://vault.example.com", token=os.environ["VAULT_TOKEN"])
secret = client.secrets.kv.v2.read_secret_version(path="netops/cli")
device["password"] = secret["data"]["data"]["password"]

The routers.json and switches.json files in ciscocmd1 should contain device hosts and types only. Credentials live somewhere else and are merged in at runtime. Once you make this split, you can also commit the inventory to git without flinching, which is the actual goal.

A related point: prefer SSH key authentication over passwords wherever your devices support it. Netmiko accepts use_keys=True and key_file= parameters. Modern Cisco IOS-XE and NX-OS support public-key user auth. For TACACS-only environments you are stuck with passwords, but on smaller estates and labs, public keys remove a whole class of problem.

Dry-run for config changes

A read-only audit script is hard to break. A config-pushing script gets exciting fast. The pattern for safe config automation is “read state → compute desired changes → preview → optionally apply”. Never wire a script straight to send_config_set and run it across fifty devices.

Here is a structure that has worked for me:

def plan_changes(current_config, desired_config):
    """Compute the minimum set of config commands to reach desired state.
    Returns a list of CLI lines, or [] if no change needed."""
    # Implementation depends on the change. For interface descriptions:
    return [...]

def apply_or_preview(conn, planned, apply=False):
    if not planned:
        return "no change"
    if not apply:
        return "WOULD APPLY:\n" + "\n".join(planned)
    output = conn.send_config_set(planned)
    return "APPLIED:\n" + output

The script’s CLI takes an --apply flag that defaults to False. The first run is always a dry-run. The output goes to a file, you read it, and only then do you re-run with --apply. For anything important, also do a show running-config | section <thing> before and after, save both, and diff them. Trust the diff, not the script’s “applied” message.

The other half of safety is rollback. NAPALM has this built in (compare/replace/rollback semantics) and the next post in this series covers when to reach for it. With raw Netmiko, the cheapest rollback is to capture the running config to a local file before you change anything and have a separate script that can push it back.

Logging

print() is fine for a one-shot run. The moment you start running this script in cron or in CI, you need real logs. Netmiko itself has a session log that captures everything sent and received per-device:

device["session_log"] = f"logs/{device['host']}.log"

That single line gives you a per-device transcript on disk. Combined with a Python logging setup at the script level, you get a clean separation between “what happened on this device” (session logs) and “what the orchestrator did” (run log).

Rotate the session logs. They are useful but they grow.

When the script outgrows itself

There is a point at which cmdrunner.py stops scaling and you need a real framework. The signals are clear when you see them: you start wanting to filter the inventory (“only the core routers”, “only the boxes in Manchester”), you want to run different commands against different roles, you want results in a structured store, you want hooks before/after each task. That is Nornir’s territory and there is a dedicated post on it later in this series.

But — and this is the bit that matters — Nornir does not replace Netmiko. Nornir uses Netmiko (and NAPALM, and Scrapli) as its connection plugin. Everything you have learned about Netmiko is reusable; the framework just adds inventory, parallelism, filtering, and result handling on top.

If you adopt Nornir straight from the start of a project, fine. If you adopt it after outgrowing a ciscocmd1-style script, you will find that all the device dictionaries, the credential handling, and the parsing patterns transfer directly.

What I would change in ciscocmd1 today

Looking at the repo with current eyes, the things I would add:

  1. use_textfsm=True by default, with a flag to disable for raw output.
  2. ThreadPoolExecutor with a worker count argument (default 10).
  3. Credentials from environment variables or getpass, never the JSON.
  4. Per-device session logs in a logs/ directory.
  5. JSON or CSV output mode alongside the human-readable mode.
  6. A --commands flag that takes a comma-separated list, for the common case of running one or two commands without writing a file.

None of those changes break the original pattern. They extend it. The “commands file plus inventory file plus runner” shape was the right call; what is around it is what wants growing.

A complete worked example

Pulling it together, here is a sketched-out script that takes the original idea and applies everything above:

import argparse, csv, json, os, getpass
from concurrent.futures import ThreadPoolExecutor, as_completed
from netmiko import ConnectHandler

def parse_args():
    p = argparse.ArgumentParser()
    p.add_argument("commands_file")
    p.add_argument("inventory_file")
    p.add_argument("--workers", type=int, default=10)
    p.add_argument("--out", default="results.csv")
    return p.parse_args()

def collect(dev, commands):
    try:
        with ConnectHandler(**dev, session_log=f"logs/{dev['host']}.log") as conn:
            conn.enable()
            return dev["host"], {
                cmd: conn.send_command(cmd, use_textfsm=True)
                for cmd in commands
            }, None
    except Exception as exc:
        return dev["host"], None, str(exc)

def main():
    args = parse_args()
    os.makedirs("logs", exist_ok=True)

    with open(args.commands_file) as f:
        commands = [l.strip() for l in f if l.strip()]
    with open(args.inventory_file) as f:
        devices = json.load(f)

    pw = os.environ.get("NETOPS_PASSWORD") or getpass.getpass("Password: ")
    for d in devices:
        d.setdefault("password", pw)
        d.setdefault("secret", pw)

    with ThreadPoolExecutor(max_workers=args.workers) as pool:
        futures = [pool.submit(collect, d, commands) for d in devices]
        for fut in as_completed(futures):
            host, data, err = fut.result()
            if err:
                print(f"[FAIL] {host}: {err}")
            else:
                print(f"[OK]   {host}")
                # ... write data to CSV / JSON

if __name__ == "__main__":
    main()

That is roughly four times the size of the original cmdrunner.py and an order of magnitude more useful. The bones are the same. The pattern is the same. What is new is the operational discipline around credentials, concurrency, structure, and logging — each of which is the kind of thing that is fine to skip when you are testing Netmiko in a lab and not optional once the script starts running against production.

If you have a ciscocmd1-style script of your own, the path forward is incremental: pick the change above that buys you the most today, add it, ship it, repeat. The next post in this series covers the parsing problem in much more detail, because once you have data instead of text the rest of the pipeline gets easy.