Chapter 3: Python Network Automation with Netmiko

Learning Objectives

Pre-Quiz: Test Your Baseline Knowledge

Answer these questions before reading the chapter. Don't worry if you don't know the answers — your goal is to measure what you already know.

Pre-Quiz — Section 1: Netmiko Fundamentals

1. What problem does Netmiko solve that plain Paramiko cannot handle well?

Paramiko does not support Python 3, so Netmiko provides a Python 3 wrapper Paramiko is unencrypted; Netmiko adds TLS on top of SSH Paramiko has no knowledge of network device CLI state machines, prompt patterns, or vendor quirks — Netmiko wraps it with that awareness Paramiko only works on Unix servers; Netmiko adds Windows support

2. Which device_type value should you use for a Cisco Catalyst 9300 running IOS XE?

cisco_ios cisco_xe cisco_cat9k cisco_nxos

3. You call connection.send_command("show ip route"). What mode must the device be in for Netmiko to issue this command?

Global configuration mode (Router(config)#) Interface configuration mode (Router(config-if)#) EXEC mode (Router> or Router#) ROM monitor mode (rommon)

4. Why is using Netmiko as a context manager (with ConnectHandler(...) as conn:) the preferred production pattern?

It makes the connection read-only, preventing accidental configuration changes It automatically calls disconnect() even if an exception is raised, preventing VTY line exhaustion Context managers are required for send_config_set() to work correctly It enables multi-threading automatically by managing thread locks

5. What does send_config_set() do automatically that you do NOT need to do manually?

Saves the configuration with write memory Enters global configuration mode with configure terminal and exits with end Reboots the device if configuration errors are detected Verifies each command against the device's running config before sending it
Pre-Quiz — Section 2: Configuration Management

6. After pushing a configuration with send_config_set(), which Netmiko method persists the changes across a device reload?

conn.commit() conn.save_config() conn.write_memory() No action needed — IOS XE auto-saves configuration

7. A command like reload in 10 prompts the device for confirmation. Which Netmiko parameter prevents the script from hanging?

auto_confirm=True strip_prompt=False expect_string=r"Proceed with reload\?" timeout=0
Pre-Quiz — Section 3: Structured Output Parsing

8. When you pass use_textfsm=True to send_command() and a matching template exists, what Python type does the method return?

A JSON string A single flat dictionary A list of dictionaries A pandas DataFrame

9. Which parsing backend would you choose for deep analysis of BGP neighbor states using Cisco's official schema?

TextFSM with ntc-templates Genie (pyATS) Regular expressions applied directly to the raw string XML parsing via show bgp summary | xml
Pre-Quiz — Section 4: Multi-Device Automation

10. Why is ThreadPoolExecutor (threading) preferred over ProcessPoolExecutor (multiprocessing) for Netmiko SSH automation?

Threads are faster than processes for all Python workloads due to lower overhead SSH automation is I/O-bound, so threads can overlap their network wait times — the GIL is released during I/O ProcessPoolExecutor cannot open SSH sockets Cisco devices reject connections from multiple processes on the same IP address

Section 1: Netmiko Fundamentals

1.1 What Is Netmiko and Why Does It Exist?

SSH was designed for human operators, not scripts. The raw Paramiko library can open SSH connections but has no understanding of Cisco CLI state machines — it cannot detect when a prompt has appeared, distinguish between user EXEC and global config mode, or handle the timing quirks of different vendor devices.

Netmiko — created by Kirk Byers in 2014 — wraps Paramiko with a network-device-aware layer. It ships with built-in support for over 80 device types including every major Cisco platform. The analogy: if Paramiko is raw electrical current, Netmiko is a power outlet — same energy, but shaped for the devices you actually plug in.

1.2 The ConnectHandler: Your Entry Point

Every Netmiko session starts with ConnectHandler. Pass it a device dictionary with type, address, and credentials. Netmiko handles the SSH handshake, login, and prompt negotiation automatically.

from netmiko import ConnectHandler

device = {
    "device_type": "cisco_xe",      # IOS XE (Catalyst 9K, ASR, CSR)
    "host": "192.168.1.1",
    "username": "admin",
    "password": "cisco123",
    "secret": "enable_secret",      # For privilege escalation (optional)
}

connection = ConnectHandler(**device)
print(connection.find_prompt())     # Confirms successful login
connection.disconnect()

The device_type parameter is critical — it controls which prompt patterns Netmiko expects and how it handles mode transitions.

Platformdevice_type
IOS XE (Catalyst 9K, ASR, CSR)cisco_xe
Classic IOScisco_ios
IOS XRcisco_xr
NX-OScisco_nxos
ASAcisco_asa
Animation: SSH Session Lifecycle
💻
Python Script
🌐
Netmiko
ConnectHandler
🖧
Cisco IOS XE
Script sends command Netmiko handles prompt detection Device returns output
Command packet
Response packet
sequenceDiagram participant Script as Python Script participant Netmiko as Netmiko (ConnectHandler) participant Device as Cisco IOS XE Device Script->>Netmiko: ConnectHandler(**device) Netmiko->>Device: TCP connect (port 22) Device-->>Netmiko: TCP ACK Netmiko->>Device: SSH handshake Device-->>Netmiko: SSH session established Netmiko->>Device: Send username + password Device-->>Netmiko: Login banner + prompt (Router>) Netmiko->>Netmiko: Detect prompt via device_type Netmiko-->>Script: Connection object ready Script->>Netmiko: send_command("show version") Netmiko->>Device: "show version\n" Device-->>Netmiko: Output + prompt Netmiko-->>Script: Output string Script->>Netmiko: disconnect() Netmiko->>Device: SSH close

1.3 send_command vs. send_config_set

send_command() is for operational (read-only) commands: show, ping, traceroute. It sends one command, waits for the prompt to return, and returns the output as a string.

send_config_set() is for pushing configuration. It accepts a list of commands, automatically issues configure terminal, sends each command in sequence, then exits with end.

Attributesend_command()send_config_set()
PurposeOperational / readConfiguration / write
Mode entryNone (stays in EXEC)Auto-enters config t
Mode exitNoneAuto-issues end
InputSingle stringList of strings
OutputRaw CLI textConfig session transcript

1.4 Session Management and the Context Manager Pattern

Cisco devices typically have only 5–16 VTY lines. Unclosed SSH connections hold VTY lines indefinitely. Use the context manager pattern to guarantee cleanup:

with ConnectHandler(**device) as connection:
    output = connection.send_command("show version")
    print(output)
# disconnect() is called automatically here

1.5 Privilege Mode and Enable

Include "secret" in the device dictionary and call enable() after connecting when privilege escalation is required. If the user account is already granted privilege 15 (common with RADIUS/TACACS+), this step is unnecessary.

Key Points — Section 1

Section 2: Configuration Management with Netmiko

2.1 Deploying Configuration at Scale

Encoding intended network state as Python lists and deploying it with send_config_set() ensures every device receives identical configuration — eliminating the typos and inconsistencies of manual CLI work.

standard_config = [
    "ntp server 10.0.1.100",
    "ntp server 10.0.1.101 prefer",
    "logging buffered 16384 informational",
    "logging host 10.0.2.50",
    "no logging console",
]

with ConnectHandler(**device) as conn:
    output = conn.send_config_set(standard_config)
    conn.save_config()   # Issues "write memory" — never skip this!

2.2 Configuration Backup Automation

A modular backup function that takes a device dictionary and backup directory is easy to reuse in loops and concurrent executors:

from datetime import datetime
import os

def backup_device_config(device: dict, backup_dir: str = "./backups") -> str:
    os.makedirs(backup_dir, exist_ok=True)
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    filename = f"{backup_dir}/backup_{device['host']}_{timestamp}.txt"

    with ConnectHandler(**device) as conn:
        running_config = conn.send_command("show running-config")

    with open(filename, "w") as f:
        f.write(running_config)

    return filename

2.3 Verifying Configuration After Push

The push-then-verify pattern is the foundation of reliable automation. After every configuration change, read back the relevant state to confirm it took effect:

with ConnectHandler(**device) as conn:
    conn.send_config_set(["ntp server 10.0.1.100"])
    conn.save_config()

    output = conn.send_command("show ntp associations")
    if "10.0.1.100" in output:
        print(f"[{device['host']}] NTP server confirmed.")
    else:
        print(f"[{device['host']}] WARNING: NTP server not yet visible.")

This pattern also becomes the basis for drift detection: run the verification step alone (without the push) to audit whether a device still matches intended state.

flowchart TD A([Start]) --> B[Connect via ConnectHandler] B --> C[Build config command list] C --> D[send_config_set with commands] D --> E[call save_config] E --> F[send_command to verify] F --> G{Expected value present?} G -->|Yes| H[Log success] G -->|No| I[Log WARNING] I --> J{Retry?} J -->|Yes| D J -->|No| K[Alert operator] H --> L[disconnect] K --> L L --> M([End])

2.4 Commands That Require Confirmation

Use expect_string when a command prompts for confirmation, otherwise Netmiko will hang waiting for a prompt that never matches:

output = conn.send_command(
    "reload in 10",
    expect_string=r"Proceed with reload\?",
)
output += conn.send_command("yes", expect_string=r"#")

Key Points — Section 2

Section 3: Structured Output Parsing

3.1 The Problem with Raw CLI Text

Raw show command output is human-readable but machine-hostile. Parsing column offsets, splitting lines, and handling platform variations across dozens of commands is unsustainable. Structured parsing converts CLI text into Python data structures — lists of dicts or nested dicts — so you access fields by name.

3.2 TextFSM with ntc-templates

Pass use_textfsm=True to send_command(). When a matching template from the community-maintained ntc-templates library exists, Netmiko returns a list of flat dictionaries:

parsed = conn.send_command("show ip interface brief", use_textfsm=True)

for intf in parsed:
    if intf["status"] != "up" or intf["proto"] != "up":
        print(f"ALERT: {intf['intf']} ({intf['ipaddr']}) is {intf['status']}/{intf['proto']}")

3.3 Genie Parser Integration

Cisco's official Genie library (part of pyATS) returns deeply nested dictionaries following a rich, vendor-documented schema. This is the right choice for complex protocol analysis:

bgp_data = conn.send_command("show ip bgp summary", use_genie=True)

neighbors = (
    bgp_data
    .get("vrf", {})
    .get("default", {})
    .get("neighbor", {})
)

for ip, data in neighbors.items():
    state = data.get("session_state", "unknown")
    print(f"BGP Neighbor: {ip} | State: {state}")

3.4 TextFSM vs. Genie: Choosing the Right Tool

FeatureTextFSM (ntc-templates)Genie (pyATS)
Template sourceCommunity-maintainedCisco official
Output formatList of flat dictsNested dicts (rich schema)
Vendor coverageMulti-vendor (broad)Cisco-focused (deep)
Installation sizeLightweightLarge (pyATS framework)
Best forQuick audits, multi-vendorBGP/OSPF/EIGRP deep analysis
Fallback behaviorReturns raw string if no templateReturns raw string if parser fails
graph TD A[Raw CLI Text from send_command] --> B{Parser flag?} B -->|use_textfsm=True| C[TextFSM + ntc-templates] B -->|use_genie=True| D[Genie / pyATS] B -->|No flag| E[Raw string returned] C --> F{Template found?} F -->|Yes| G[List of flat dicts] F -->|No| H[Raw string fallback] D --> I{Parser supported?} I -->|Yes| J[Nested dict — rich schema] I -->|No| K[Raw string fallback] G --> L{Use case} J --> L L -->|Quick audit, multi-vendor| M[Use TextFSM] L -->|BGP/OSPF deep analysis| N[Use Genie]

3.5 Writing Reusable Parsing Libraries

Avoid scattering use_textfsm=True flags throughout ad-hoc scripts. Centralize parsing in a dedicated module to isolate parser changes from business logic and enable unit testing with recorded CLI output:

# netops/parsers.py

def get_interfaces(conn) -> list[dict]:
    return conn.send_command("show ip interface brief", use_textfsm=True)

def get_bgp_summary(conn) -> dict:
    return conn.send_command("show ip bgp summary", use_genie=True)

def get_routes(conn, prefix_filter: str = None) -> list[dict]:
    routes = conn.send_command("show ip route", use_textfsm=True)
    if prefix_filter:
        return [r for r in routes if r.get("network", "").startswith(prefix_filter)]
    return routes

Key Points — Section 3

Section 4: Multi-Device Automation and Error Handling

4.1 Sequential vs. Concurrent Execution

At 3–5 seconds per device, a sequential loop over 100 devices takes 5–8 minutes. Netmiko SSH is I/O-bound — threads spend most of their time waiting for network responses, not computing. Threading overlaps those waits: ThreadPoolExecutor is the standard tool.

PropertyI/O-bound (Netmiko SSH)CPU-bound (data processing)
BottleneckWaiting for network responsesProcessor cycles
Correct toolThreadPoolExecutorProcessPoolExecutor
Python GIL impactGIL released during I/O waitsGIL blocks parallel execution

4.2 Loading Device Inventories from YAML

Store device lists in version-controlled YAML and inject credentials from environment variables — never commit credentials to source control:

import yaml, os

def load_inventory(path: str) -> list[dict]:
    password = os.environ.get("DEVICE_PASSWORD")
    if not password:
        raise EnvironmentError("DEVICE_PASSWORD not set.")
    with open(path) as f:
        data = yaml.safe_load(f)
    for device in data["devices"]:
        device["password"] = password
    return data["devices"]

4.3 Concurrent Execution with ThreadPoolExecutor

The production-standard pattern for parallel Netmiko operations — study the exception handling and finally disconnect carefully:

from concurrent.futures import ThreadPoolExecutor, as_completed
from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException

def collect_show_version(device: dict) -> dict:
    host = device["host"]
    conn = None
    try:
        conn = ConnectHandler(**device)
        output = conn.send_command("show version")
        log.info(f"[{host}] Success.")
        return {"host": host, "output": output, "status": "success"}

    except NetmikoTimeoutException:
        log.error(f"[{host}] Connection timed out.")
        return {"host": host, "output": None, "status": "timeout"}

    except NetmikoAuthenticationException:
        log.error(f"[{host}] Authentication failed.")
        return {"host": host, "output": None, "status": "auth_failed"}

    except Exception as e:
        log.exception(f"[{host}] Unexpected error: {e}")
        return {"host": host, "output": None, "status": f"error: {e}"}

    finally:
        if conn:
            conn.disconnect()   # Always runs, even after exceptions

results = []
with ThreadPoolExecutor(max_workers=10) as executor:
    futures = {executor.submit(collect_show_version, dev): dev for dev in devices}
    for future in as_completed(futures):
        results.append(future.result())
Animation: ThreadPoolExecutor — Parallel Device Sessions
Device Queue
10.0.1.1
10.0.1.2
10.0.1.3
10.0.1.4
10.0.1.5
ThreadPoolExecutor (max_workers=4)
Worker Threads
Thread 1 — 10.0.1.1 (done)
Thread 2 — 10.0.1.2 (SSH handshake...)
Thread 3 — 10.0.1.3 (show version...)
Thread 4 — 10.0.1.4 (waiting for prompt)
Results
10.0.1.1 — success
10.0.1.2 — pending...
10.0.1.3 — pending...
10.0.1.6 — timeout
flowchart TD A([Start]) --> B[Load inventory.yaml] B --> C[Inject credentials from env vars] C --> D[Create ThreadPoolExecutor\nmax_workers = N] D --> E[Submit worker function for each device] E --> F1[Thread 1: Device 10.0.1.1] E --> F2[Thread 2: Device 10.0.1.2] E --> F3[Thread 3: Device 10.0.1.3] E --> F4[Thread N: Device ...] F1 --> G1{Connect OK?} G1 -->|Yes| H1[Run command] G1 -->|Timeout| I1[status=timeout] G1 -->|Auth fail| J1[status=auth_failed] H1 --> K1[disconnect in finally] I1 --> K1 J1 --> K1 F2 --> G2{Connect OK?} G2 -->|Yes| H2[Run command] H2 --> K2[disconnect in finally] K1 --> L[Collect via as_completed] K2 --> L F3 --> L F4 --> L L --> M[Summarize success / failed] M --> N([End])

4.4 Tuning max_workers

Inventory sizeRecommended max_workers
< 20 devices5–10
20–100 devices10–20
100–500 devices20–50 (test VTY limits first)
500+ devicesConsider Nornir or Ansible as orchestrator

4.5 Exception Handling Reference

ExceptionRoot CauseRecommended Action
NetmikoTimeoutExceptionDevice unreachable, firewall blockingLog, skip device, alert on-call
NetmikoAuthenticationExceptionWrong credentials, expired accountLog, do NOT retry (lock risk)
ReadTimeoutCommand output took too longIncrease read_timeout parameter
SSHExceptionSSH key mismatch, algorithm failureCheck StrictHostKeyChecking settings
ExceptionOS errors, network drops, anything elseUse log.exception() to capture traceback

4.6 Tuning for Slow Devices

device = {
    "device_type": "cisco_xe",
    "host": "10.0.0.1",
    "username": "admin",
    "password": "cisco",
    "conn_timeout": 15,         # TCP connection timeout (default: 10s)
    "banner_timeout": 20,       # SSH banner wait (default: 15s)
    "global_delay_factor": 2,   # Multiplier for all internal wait timers
    "read_timeout": 30,         # Max wait for show command output
}

4.7 Production Logging Best Practices

Never use print() in production automation. Use Python's logging module with timestamps, levels, and dual handlers (file + console) to create a complete audit trail.

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
    handlers=[
        logging.FileHandler("netops_automation.log"),
        logging.StreamHandler(),
    ]
)
log = logging.getLogger(__name__)

log.info("Config pushed successfully.")
log.error("Connection failed.")
log.exception("Unexpected exception.")  # Logs full traceback

Key Points — Section 4

Post-Quiz: Test Your Understanding After Reading

Answer these questions after completing the chapter. Compare your scores to measure what you learned.

Post-Quiz — Section 1: Netmiko Fundamentals

1. What problem does Netmiko solve that plain Paramiko cannot handle well?

Paramiko does not support Python 3, so Netmiko provides a Python 3 wrapper Paramiko is unencrypted; Netmiko adds TLS on top of SSH Paramiko has no knowledge of network device CLI state machines, prompt patterns, or vendor quirks — Netmiko wraps it with that awareness Paramiko only works on Unix servers; Netmiko adds Windows support

2. Which device_type value should you use for a Cisco Catalyst 9300 running IOS XE?

cisco_ios cisco_xe cisco_cat9k cisco_nxos

3. You call connection.send_command("show ip route"). What mode must the device be in for Netmiko to issue this command?

Global configuration mode (Router(config)#) Interface configuration mode (Router(config-if)#) EXEC mode (Router> or Router#) ROM monitor mode (rommon)

4. Why is using Netmiko as a context manager (with ConnectHandler(...) as conn:) the preferred production pattern?

It makes the connection read-only, preventing accidental configuration changes It automatically calls disconnect() even if an exception is raised, preventing VTY line exhaustion Context managers are required for send_config_set() to work correctly It enables multi-threading automatically by managing thread locks

5. What does send_config_set() do automatically that you do NOT need to do manually?

Saves the configuration with write memory Enters global configuration mode with configure terminal and exits with end Reboots the device if configuration errors are detected Verifies each command against the device's running config before sending it
Post-Quiz — Section 2: Configuration Management

6. After pushing a configuration with send_config_set(), which Netmiko method persists the changes across a device reload?

conn.commit() conn.save_config() conn.write_memory() No action needed — IOS XE auto-saves configuration

7. A command like reload in 10 prompts the device for confirmation. Which Netmiko parameter prevents the script from hanging?

auto_confirm=True strip_prompt=False expect_string=r"Proceed with reload\?" timeout=0
Post-Quiz — Section 3: Structured Output Parsing

8. When you pass use_textfsm=True to send_command() and a matching template exists, what Python type does the method return?

A JSON string A single flat dictionary A list of dictionaries A pandas DataFrame

9. Which parsing backend would you choose for deep analysis of BGP neighbor states using Cisco's official schema?

TextFSM with ntc-templates Genie (pyATS) Regular expressions applied directly to the raw string XML parsing via show bgp summary | xml

10. If send_command("show interfaces", use_textfsm=True) returns a raw string instead of a list, what most likely happened?

TextFSM is not installed No ntc-templates template exists for that command/platform combination — Netmiko fell back to the raw string The device is offline TextFSM only works with use_genie=True
Post-Quiz — Section 4: Multi-Device Automation

11. Why is ThreadPoolExecutor (threading) preferred over ProcessPoolExecutor (multiprocessing) for Netmiko SSH automation?

Threads are faster than processes for all Python workloads due to lower overhead SSH automation is I/O-bound, so threads can overlap their network wait times — the GIL is released during I/O ProcessPoolExecutor cannot open SSH sockets Cisco devices reject connections from multiple processes on the same IP address

12. Why should you never retry a NetmikoAuthenticationException in your error handling logic?

Retrying always raises the same exception, causing an infinite loop Repeated failed authentication attempts can trigger account lockout on the device Netmiko's authentication module does not support retry logic Re-authenticating would require creating a new ConnectHandler object anyway

13. A device is responding slowly over a high-latency WAN link, causing ReadTimeout errors on show running-config. What is the most appropriate fix?

Reduce max_workers to 1 so only one thread accesses the device at a time Switch from send_command() to send_command_timing() with a 1-second delay Increase the read_timeout parameter and/or set global_delay_factor=2 in the device dictionary Use use_textfsm=True to parse the output faster and reduce timeout risk

14. You have 400 devices and each has a maximum of 8 VTY lines configured. What is the maximum safe max_workers you should use per device?

400 — one thread per device 1 — only one session per device is ever allowed Up to 8, but your script only opens one session per device per run — the limit is per-device, so the pool size should be constrained by the total number of concurrent device sessions 50 is always the right answer regardless of VTY configuration

15. Why should device credentials be loaded from environment variables rather than stored directly in an inventory YAML file?

YAML does not support string values, so passwords cannot be stored there Environment variables are encrypted by the OS; YAML files are not Credentials in version-controlled YAML files are exposed to anyone with repository access — environment variables keep secrets out of source control Netmiko's load_inventory() function only reads from environment variables

Your Progress

Answer Explanations