Build Python scripts using Netmiko to connect to and manage Cisco IOS XE devices over SSH
Automate configuration deployment and running-configuration backups with Netmiko
Implement structured output parsing using TextFSM and Genie parsers to convert CLI text into Python data structures
Handle multi-device automation at scale using concurrent.futures, robust exception handling, and structured logging
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 wrapperParamiko is unencrypted; Netmiko adds TLS on top of SSHParamiko has no knowledge of network device CLI state machines, prompt patterns, or vendor quirks — Netmiko wraps it with that awarenessParamiko 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_ioscisco_xecisco_cat9kcisco_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 changesIt automatically calls disconnect() even if an exception is raised, preventing VTY line exhaustionContext managers are required for send_config_set() to work correctlyIt 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 memoryEnters global configuration mode with configure terminal and exits with endReboots the device if configuration errors are detectedVerifies 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?
7. A command like reload in 10 prompts the device for confirmation. Which Netmiko parameter prevents the script from hanging?
auto_confirm=Truestrip_prompt=Falseexpect_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 stringA single flat dictionaryA list of dictionariesA pandas DataFrame
9. Which parsing backend would you choose for deep analysis of BGP neighbor states using Cisco's official schema?
TextFSM with ntc-templatesGenie (pyATS)Regular expressions applied directly to the raw stringXML 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 overheadSSH automation is I/O-bound, so threads can overlap their network wait times — the GIL is released during I/OProcessPoolExecutor cannot open SSH socketsCisco 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.
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.
Attribute
send_command()
send_config_set()
Purpose
Operational / read
Configuration / write
Mode entry
None (stays in EXEC)
Auto-enters config t
Mode exit
None
Auto-issues end
Input
Single string
List of strings
Output
Raw CLI text
Config 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
Netmiko wraps Paramiko with CLI-aware prompt detection for 80+ device types; device_type is the critical parameter.
ConnectHandler handles the full SSH handshake, login, and prompt negotiation automatically.
Use send_command() for read operations (stays in EXEC) and send_config_set() for writes (auto-enters/exits config mode).
Always use the context manager (with ConnectHandler(...) as conn:) to guarantee disconnect() is called and VTY lines are freed.
Specify "secret" and call conn.enable() when the device requires explicit privilege escalation.
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
send_config_set() manages the full config session lifecycle; you only supply the actual configuration lines.
Always call save_config() after pushing changes — skipping this means a device reload reverts your work.
Pair every configuration push with an immediate verification step (send_command + assertion) to detect failures fast.
Build backup functions as modular, reusable components that accept a device dictionary — this makes concurrent use simple.
Use expect_string for commands that require interactive confirmation to prevent script hangs.
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
Feature
TextFSM (ntc-templates)
Genie (pyATS)
Template source
Community-maintained
Cisco official
Output format
List of flat dicts
Nested dicts (rich schema)
Vendor coverage
Multi-vendor (broad)
Cisco-focused (deep)
Installation size
Lightweight
Large (pyATS framework)
Best for
Quick audits, multi-vendor
BGP/OSPF/EIGRP deep analysis
Fallback behavior
Returns raw string if no template
Returns 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
Never build audit logic on raw CLI strings — structured parsing makes your scripts platform-portable and maintainable.
use_textfsm=True returns a list of flat dicts; good for quick multi-vendor interface/route audits.
use_genie=True returns deeply nested dicts using Cisco's official schema; ideal for BGP, OSPF, and EIGRP analysis.
Both parsers fall back to the raw string if no template/schema is found — always check the return type before accessing keys.
Wrap parsing calls in a dedicated module to enable swapping parsers and unit testing without touching business logic.
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.
Property
I/O-bound (Netmiko SSH)
CPU-bound (data processing)
Bottleneck
Waiting for network responses
Processor cycles
Correct tool
ThreadPoolExecutor
ProcessPoolExecutor
Python GIL impact
GIL released during I/O waits
GIL 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())
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 size
Recommended max_workers
< 20 devices
5–10
20–100 devices
10–20
100–500 devices
20–50 (test VTY limits first)
500+ devices
Consider Nornir or Ansible as orchestrator
4.5 Exception Handling Reference
Exception
Root Cause
Recommended Action
NetmikoTimeoutException
Device unreachable, firewall blocking
Log, skip device, alert on-call
NetmikoAuthenticationException
Wrong credentials, expired account
Log, do NOT retry (lock risk)
ReadTimeout
Command output took too long
Increase read_timeout parameter
SSHException
SSH key mismatch, algorithm failure
Check StrictHostKeyChecking settings
Exception
OS errors, network drops, anything else
Use 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.
SSH automation is I/O-bound — use ThreadPoolExecutor (not multiprocessing) to overlap network wait times across devices.
Store device inventories in version-controlled YAML; inject credentials from environment variables — never commit passwords to source control.
Always place conn.disconnect() in the finally block so VTY lines are freed even when exceptions occur.
Never retry NetmikoAuthenticationException — repeated failed logins risk account lockout on the device.
Use structured logging with timestamps and file handlers in production; log.exception() captures full tracebacks automatically.
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 wrapperParamiko is unencrypted; Netmiko adds TLS on top of SSHParamiko has no knowledge of network device CLI state machines, prompt patterns, or vendor quirks — Netmiko wraps it with that awarenessParamiko 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_ioscisco_xecisco_cat9kcisco_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 changesIt automatically calls disconnect() even if an exception is raised, preventing VTY line exhaustionContext managers are required for send_config_set() to work correctlyIt 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 memoryEnters global configuration mode with configure terminal and exits with endReboots the device if configuration errors are detectedVerifies 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?
7. A command like reload in 10 prompts the device for confirmation. Which Netmiko parameter prevents the script from hanging?
auto_confirm=Truestrip_prompt=Falseexpect_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 stringA single flat dictionaryA list of dictionariesA pandas DataFrame
9. Which parsing backend would you choose for deep analysis of BGP neighbor states using Cisco's official schema?
TextFSM with ntc-templatesGenie (pyATS)Regular expressions applied directly to the raw stringXML 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 installedNo ntc-templates template exists for that command/platform combination — Netmiko fell back to the raw stringThe device is offlineTextFSM 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 overheadSSH automation is I/O-bound, so threads can overlap their network wait times — the GIL is released during I/OProcessPoolExecutor cannot open SSH socketsCisco 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 loopRepeated failed authentication attempts can trigger account lockout on the deviceNetmiko's authentication module does not support retry logicRe-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 timeSwitch from send_command() to send_command_timing() with a 1-second delayIncrease the read_timeout parameter and/or set global_delay_factor=2 in the device dictionaryUse 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 device1 — only one session per device is ever allowedUp 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 sessions50 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 thereEnvironment variables are encrypted by the OS; YAML files are notCredentials in version-controlled YAML files are exposed to anyone with repository access — environment variables keep secrets out of source controlNetmiko's load_inventory() function only reads from environment variables