Netmiko Tutorial for Cisco: SSH Automation That Proves It Worked

A netmiko walkthrough built from a live capture against IOS-XE 17.18.2: connect, read, parse with TextFSM, push with send_config_set, then prove whether anything actually changed. Includes the ntc-templates key names that fail silently.

Terminal card showing netmiko parsing show ip interface brief and R1 learning pushed loopbacks via OSPF

You have a four-line change to make on forty routers, and the only management interface everyone agrees works is SSH. That is what Netmiko is for: a thin wrapper around Paramiko that knows how Cisco prompts behave, disables the pager for you, waits for the trailing # before handing anything back, and otherwise stays out of the way.

This walkthrough is a netmiko tutorial for Cisco devices built entirely from a live capture: Netmiko 4.5.0 on a Debian 13 VM driving three iol-xe routers running IOS-XE 17.18.2 in a CML lab, all sitting on one bridged Layer 2 segment. Every block of device output below is copied from that run. If you are still deciding where scripted SSH sits next to the declarative tools, the wider Python automation stack for Cisco gear covers the neighbours.

The part most tutorials skip is the part that matters in a change window: Netmiko has no idea whether your push changed anything. It will replay the same four lines a hundred times and print the same cheerful echo every time. Proving "nothing moved" is your job.

What this was captured on

The path is a real Linux host bridged into a real virtual network, which is why OSPF timers and route ages drift between blocks.

Automation hostDebian 13 (trixie), Python 3.13.5, 192.168.99.100 on ens224
Librarynetmiko 4.5.0 with ntc-templates and textfsm
DevicesR1/R2/R3, iol-xe, IOS-XE 17.18.2, 192.168.99.1-3
TopologyVM and all three routers on one broadcast domain, OSPF 1 area 0
Loginadmin / privilege 15 local user, no enable secret

Device side, SSH needs this before any of it works, and one line cannot be staged in a startup config:

username admin privilege 15 secret Cisco123
ip domain name pinglabz.lab
crypto key generate rsa modulus 2048
ip ssh version 2
line vty 0 4
 login local
 transport input ssh

crypto key generate rsa has to run after the box boots, which is why automated lab builds all end up with a post-boot step. (On IOL, IOS-XE calls the command deprecated and then generates the key anyway.)

Connect: ConnectHandler, and the missing enable secret

The whole connection is a dictionary. device_type is the only field with any magic in it, because it selects the platform class that knows what a Cisco prompt looks like and which pager command to send.

from netmiko import ConnectHandler

dev = {
    "device_type": "cisco_ios",
    "host": "192.168.99.1",
    "username": "admin",
    "password": "Cisco123",
    "fast_cli": False,
}
conn = ConnectHandler(**dev)
print(conn.find_prompt())
R1#

That prompt is worth staring at. It is R1#, not R1>. Because the local user is privilege 15, Netmiko lands straight in exec mode and never answers an enable prompt, so there is no secret= key in the dict. Most tutorials show one, which is fine until someone copies the dict onto a box where the user is privilege 1 and cannot work out why config mode fails. (Lower privilege, add "secret" and call conn.enable() first.)

fast_cli=False is deliberate. Netmiko 4.x defaults it to True, which trims the internal delay factors for speed; over a bridged path to virtual IOL nodes that made reads flakier, so it was turned off for stable captures. On real hardware, leave the default alone until it bites you.

If you have not run Netmiko before, start with the CCNA lab: your first netmiko show command against a router, then pushing your first config from a script. Everything below assumes the connection already works.

Read: send_command returns the whole thing, unpaged

send_command() writes the string, then reads until it sees the prompt again. You get complete output, with no --More-- in it, because Netmiko issued terminal length 0 at login on your behalf.

conn.send_command("show version | include Software|uptime is")
conn.send_command("show ip ospf neighbor")
Cisco IOS Software [IOSXE], Linux Software (X86_64BI_LINUX-ADVENTERPRISEK9-M), Version 17.18.2, RELEASE SOFTWARE (fc3)
R1 uptime is 4 minutes

Neighbor ID     Pri   State           Dead Time   Address         Interface
2.2.2.2           1   FULL/BDR        00:00:30    192.168.99.2    Ethernet0/0
3.3.3.3           1   FULL/DR         00:00:39    192.168.99.3    Ethernet0/0

What you have there is a Python string containing a screen. That is screen-scraping, not automation. Everyone's first script reaches for a regex against that string, and everyone's first script breaks the first time a column shifts.

Parse: use_textfsm, and the key names nobody guesses right

Add one keyword argument and Netmiko runs the output through TextFSM using the bundled ntc-templates, handing you a list of dictionaries instead of a screen.

rows = conn.send_command("show ip interface brief", use_textfsm=True)
[{'intf': 'Ethernet0/0', 'ipaddr': '192.168.99.1', 'status': 'up', 'proto': 'up'},
 {'intf': 'Ethernet0/1', 'ipaddr': 'unassigned', 'status': 'administratively down', 'proto': 'down'},
 {'intf': 'Loopback14', 'ipaddr': '14.14.14.1', 'status': 'up', 'proto': 'up'}]

Now read those keys again. They are intf, ipaddr, status and proto. They are not interface and ip_address, which is what any reasonable person would guess, and what the first version of this capture script did guess.

Here is why that matters more than it sounds. TextFSM does not raise. A dictionary lookup with .get(), an f-string over a missing key, a template that matches zero rows: all of them produce None or an empty list and keep going. The script ran to completion, printed a tidy report full of None, and exited zero. Nothing in the traceback told anyone, because there was no traceback.

The fix costs one line, once, per command you have not parsed before:

sorted(rows[0].keys())
['intf', 'ipaddr', 'proto', 'status']

Do that before you write the loop, not after the report looks wrong. The other reliable source is the template itself: the Value lines at the top of cisco_ios_show_ip_interface_brief.textfsm in the ntc-templates repo are the definitive key list.

Once the keys are right, a health check stops being a regex problem and becomes a list comprehension:

nbrs = conn.send_command("show ip ospf neighbor", use_textfsm=True)
[n for n in nbrs if not n["state"].startswith("FULL")]
[]

An empty list is the entire check. Every adjacency is FULL, with no column counting and no include filter that silently stops matching after an upgrade. show version parses the same way, into version, hostname, uptime and serial, which is the shape you want for an inventory. If that data is heading somewhere else, handling JSON from network devices in Python picks up there, and running Python on the switch itself is the other end of the same idea.

Write: send_config_set builds the config session for you

Pass a list of lines. Netmiko wraps them in configure terminal and end, sends them one at a time, and returns the whole session echo as a string.

cfg = [
    "interface Loopback14",
    " description PingLabz RES-0014 pushed by netmiko",
    " ip address 14.14.14.1 255.255.255.255",
]
print(conn.send_config_set(cfg))
configure terminal
Enter configuration commands, one per line.  End with CNTL/Z.
R1(config)#interface Loopback14
R1(config-if)# description PingLabz RES-0014 pushed by netmiko
R1(config-if)# ip address 14.14.14.1 255.255.255.255
R1(config-if)#end
R1#

The absence of a line starting with % is how you know every command was accepted, and scanning the returned string for "%" is a real check worth automating. It is also about the only signal that return value gives you.

Netmiko will not save for you, so add the write yourself:

conn.send_command("write memory")
Building configuration...
[OK]

Prove it: Netmiko cannot tell you whether anything changed

This is what separates a demo from a change process. Push the identical list a second time, nothing altered in between.

configure terminal
Enter configuration commands, one per line.  End with CNTL/Z.
R1(config)#interface Loopback14
R1(config-if)# description PingLabz RES-0014 pushed by netmiko
R1(config-if)# ip address 14.14.14.1 255.255.255.255
R1(config-if)#end
R1#

Byte for byte the same output as the first run. IOS re-accepted every line without a murmur, because the CLI is imperative: you are not describing a desired end state, you are typing commands, and typing a command that is already true is not an error. The string Netmiko handed back says nothing about whether the device moved.

The only way to know is to diff the device's own state around the operation:

before = conn.send_command("show running-config")
conn.send_config_set(cfg)
after  = conn.send_command("show running-config")

import difflib
list(difflib.unified_diff(before.splitlines(), after.splitlines()))
<empty diff - running-config is byte-for-byte identical>

Empty list, zero diff lines. That is the proof, and producing it was your job, not the library's. For contrast, the same diff around the first push, when something genuinely did change:

--- running-config BEFORE 1st run
+++ running-config AFTER 1st run
@@ -20,2 +20,5 @@
  ip ospf 1 area 0
+interface Loopback14
+ description PingLabz RES-0014 pushed by netmiko
+ ip address 14.14.14.1 255.255.255.255
 interface Ethernet0/0

Hold that next to a declarative tool. NAPALM builds a candidate config, asks the device for the diff, and returns changed=False with an empty diff when there is nothing to do, before it commits. That guarantee is the whole reason to move up a layer: netmiko gives you the session, Nornir gives you the inventory and NAPALM gives you the diff.

Scale: one loop, three devices, one independent witness

This is why anyone tolerates screen-scraping at all. One inventory dictionary, one credential set, one loop:

INV = {
    "R1": {"host": "192.168.99.1", "ip": "14.14.14.1"},
    "R2": {"host": "192.168.99.2", "ip": "14.14.14.2"},
    "R3": {"host": "192.168.99.3", "ip": "14.14.14.3"},
}
for name, d in INV.items():
    c = ConnectHandler(host=d["host"], **BASE)
    c.send_config_set([
        "interface Loopback14",
        " ip address %s 255.255.255.255" % d["ip"],
        " ip ospf 1 area 0",
    ])
    c.send_command("write memory")
    c.disconnect()

Each device confirms its own line:

! R2
Loopback14             14.14.14.2      YES manual up                    up
! R3
Loopback14             14.14.14.3      YES manual up                    up

Except that proof is weak. You wrote to R2, then read from R2, over the same session with the same library. If anything in that path were lying to you (a stale buffer, a prompt-matching bug, a mocked connection someone left in), you would not find out this way.

So put a witness in the loop. Each pushed loopback carries ip ospf 1 area 0, so R2 and R3 have to advertise it and R1, never told about any of it, has to learn it independently:

conn.send_command("show ip route ospf | include 14.14.14")
O        14.14.14.2 [110/11] via 192.168.99.2, 00:00:06, Ethernet0/0
O        14.14.14.3 [110/11] via 192.168.99.3, 00:00:03, Ethernet0/0

R1 learned both prefixes through OSPF. A routing protocol on a device you never touched now agrees that your change landed on two real, running boxes. That is a categorically stronger claim than re-reading the device you just wrote to, and it costs one extra show. Verify from the other side of the change wherever a protocol will do the verifying for you, which is the habit testing a network with pyATS and Genie formalises.

The session log: what actually goes over the wire

Add "session_log": "session.txt" to the device dict and Netmiko writes every byte it sent and received to a file. It is the first thing to reach for when a script hangs, because it shows you exactly which prompt Netmiko was waiting for when it gave up.

R1#
R1#terminal width 511
R1#terminal length 0
R1#
R1#show version | include Software|uptime is
Cisco IOS Software [IOSXE], Linux Software (X86_64BI_LINUX-ADVENTERPRISEK9-M), Version 17.18.2, RELEASE SOFTWARE (fc3)
R1 uptime is 4 minutes
R1#
R1#exit

Notice the two lines you never wrote: terminal width 511 and terminal length 0. Netmiko sends those itself at login, which is why your output never contains --More-- and never wraps mid-column. It is also a reminder that this is a normal interactive session, landing in your AAA accounting and syslog exactly like a human typing. Automation over SSH is not a side channel.

Gotchas that cost real time

  • TextFSM fails silently. Wrong key name, no exception, None everywhere, exit code zero. Print result[0].keys() once per new command, or read the template's Value lines.
  • There is no desired state. The return value of send_config_set() is a transcript, not a change report. Identical pushes look identical. Diff show running-config, or move to a tool that does it for you.
  • send_config_set does not save. Your change survives exactly until the next reload unless you follow it with write memory.
  • Privilege level decides whether you need a secret. With privilege 15, find_prompt() gives you R1# and no secret= is needed. Anything lower and you need enable() before config mode.
  • fast_cli is on by default in Netmiko 4.x. Fast is good until it is not. On slow or virtual gear, fast_cli=False buys stability at the cost of a couple of seconds per device.
  • Older IOS images will refuse your SSH client outright. Modern Paramiko and OpenSSH dropped the key exchange and host key algorithms ancient boxes still offer, so you get a negotiation failure that looks nothing like an auth failure. Re-enabling those algorithms is a deliberate weakening of management plane security, so scope it per host and put the image upgrade on the plan.
  • Watch which Netmiko you are actually running. The Debian system package here was 4.5.0; a fresh virtualenv on the same host pulled 4.7.0. Both worked, but when behaviour differs between your laptop and the jump host, that is usually why.

Key takeaways

  • Netmiko gives you a reliable interactive SSH session with the pager disabled and prompt handling solved. That is the whole product, and it is a good one.
  • use_textfsm=True is the line between screen-scraping and automation, but verify the key names once before you trust them, because a wrong key is silent.
  • send_config_set() tells you commands were accepted (no % lines) and nothing more. It cannot tell you whether the device changed.
  • Diffing show running-config around the push is the only honest proof of "no change" with Netmiko. If you need that routinely, choosing between netmiko, NAPALM and pyATS is the next decision.
  • Verify from somewhere you did not write to. OSPF proved these pushes landed on R2 and R3 far better than re-reading R2 and R3 did.
  • Start scripted SSH here, then work outward through the rest of the Cisco automation tooling walkthroughs when your inventory outgrows a for loop.

Read next