Python for network engineers comes down to a small toolkit: Netmiko for SSH sessions, TextFSM for turning show output into data, Jinja2 for config templates, and requests for device APIs. The 10 scripts below use exactly that toolkit against Cisco IOS. They build on each other, from planning subnets to pushing config to 50 devices at once.
Every script was run before it went on this page. The ones that don’t touch a device ran for real, and you’ll see their actual output. The device scripts ran against a simulated Netmiko session, so their output is labelled representative. Copy them into a lab, change the IPs, and they’ll work against IOS.
Studying for CCNA Automation? Each script carries a tag showing which 200-901 exam topic it practises. Our CCNA Automation Workbook takes the same idea through all six exam domains, lab by lab.
Python for network engineers: what do you need before you start?
Four things. A lab, Python, two environment variables and one inventory file.
A lab with real Cisco images. These scripts talk to IOS over SSH, so you need routers and switches that run it. EVE-NG or Cisco Modeling Labs both work. If you haven’t picked one, our CML vs EVE-NG comparison covers where the images come from, and the home lab setup guide gets the hardware side done in a weekend. Script 10 needs one IOS XE device, the platform Cisco’s RESTCONF guide is written for.

The lab used throughout: R1 (10.10.10.1), R2 (10.10.10.2), SW1 (10.10.10.11), SW2 (10.10.10.12) and the IOS XE router XE1 (10.10.10.21) for script 10, all on the 10.10.10.0/24 management network.
Python 3.10 or newer, plus the libraries. Netmiko 4.8.0 (released 2026-09-21) needs Python 3.10 at minimum. Installing it pulls in Paramiko, TextFSM and the ntc-templates parser library too.
python3 -m venv venv
source venv/bin/activate
pip install netmiko jinja2 pyyaml requests pytest
Credentials in environment variables, not in the script. Secret protection is its own exam topic (4.8), and it’s a good habit anyway.
export NET_USER=admin
export NET_PASS='your-lab-password'
An inventory file. Scripts 3, 4, 5 and 9 all read this inventory.yaml:
defaults:
device_type: cisco_ios
devices:
- name: R1
host: 10.10.10.1
- name: R2
host: 10.10.10.2
- name: SW1
host: 10.10.10.11
- name: SW2
host: 10.10.10.12
Each device also needs SSH turned on and a local user. If that part’s rusty, the Cisco commands cheat sheet has the lines.
What is Netmiko, and why do network engineers start with it?
Netmiko is a Python library that handles SSH sessions to network devices for you. Its own description calls it a “multi-vendor library to simplify Paramiko SSH connections to network devices”. Paramiko does the raw SSH. Netmiko adds the parts that make network gear awkward: finding the prompt, turning off paging, entering enable and config mode, and knowing when a command has finished.
That’s why it’s the usual first library. You already know the CLI. Netmiko lets you type it from Python.
The setting that matters most is device_type. It tells Netmiko which platform it’s talking to. Netmiko 4.8.0 ships 352 SSH device type strings, counting aliases (we counted them from the library itself). The Netmiko device types you’ll meet first:
| device_type | Platform |
|---|---|
cisco_ios | Classic IOS and IOSv |
cisco_xe | IOS XE (shares the IOS driver) |
cisco_nxos | Nexus NX-OS |
cisco_xr | IOS XR |
cisco_asa | ASA firewalls |
arista_eos | Arista EOS |
juniper_junos | Junos |
Get it wrong and Netmiko can misread the prompt or time out waiting for one. So check it first when a session misbehaves.
The 10 Python scripts for network engineers
Each script has a tag showing the CCNA Automation (200-901) topic it practises. Where a script practises a job skill the exam doesn’t name, it says so.
Script 1: How do you plan subnets with Python’s ipaddress module?
Exam topic 6.2 (IP addresses, subnet masks, prefixes). Needs no device.
Start here, because it runs on your laptop with nothing else. Python’s built-in ipaddress module does the subnet maths, so you can’t fat-finger a block size. This script does VLSM. It takes a /21 site block and a host count per VLAN, then hands out the smallest subnet that fits each one, largest first.
import ipaddress
site = ipaddress.ip_network("172.16.40.0/21")
needs = {"USERS": 400, "VOICE": 120, "SERVERS": 50, "MGMT": 20}
next_ip = site.network_address
# Largest first, so every block lands on its own boundary
for name, hosts in sorted(needs.items(), key=lambda kv: kv[1], reverse=True):
prefix = 32 - (hosts + 1).bit_length() # smallest block with hosts + 2 addresses
net = ipaddress.ip_network(f"{next_ip}/{prefix}")
if not net.subnet_of(site):
raise SystemExit(f"{name} doesn't fit in {site}")
print(f"{name:8} {str(net):18} gw {str(net.network_address + 1):15} usable {net.num_addresses - 2}")
next_ip = net.broadcast_address + 1
print(f"Next free address: {next_ip}")
Real output:
USERS 172.16.40.0/23 gw 172.16.40.1 usable 510
VOICE 172.16.42.0/25 gw 172.16.42.1 usable 126
SERVERS 172.16.42.128/26 gw 172.16.42.129 usable 62
MGMT 172.16.42.192/27 gw 172.16.42.193 usable 30
Next free address: 172.16.42.224
400 users need a /23, because a /24 only gives you 254. The four VLANs use 736 of the 2,048 addresses in the /21, and the next one starts at 172.16.42.224.
Two things caught us while testing. First, the prefix line. (hosts + 1).bit_length() is the number of host bits needed to fit the hosts plus the network and broadcast addresses. We checked it at 14 edge values, including 254 (a /24) and 255 (a /23), and it picked the smallest block every time. Second, ipaddress objects don’t accept width formatting like :15 directly, so the gateway gets wrapped in str(). Leave that out and the script crashes with a TypeError.
Want the maths behind it? Subnetting Explained walks through block sizes by hand, and the subnet mask cheat sheet puts every prefix on one page.
Script 2: How do you connect to a Cisco router with Netmiko ConnectHandler?
Job skill. The exam covers device-level interfaces (3.6) but doesn’t name Netmiko.
ConnectHandler is the one function you’ll call in every Netmiko script. Give it a dictionary describing the device and it returns a live session. The with block closes the session for you, even if something fails halfway.
import os
from netmiko import ConnectHandler
r1 = {
"device_type": "cisco_ios",
"host": "10.10.10.1",
"username": os.environ["NET_USER"],
"password": os.environ["NET_PASS"],
}
with ConnectHandler(**r1) as conn:
print(conn.find_prompt())
print(conn.send_command("show ip interface brief"))
Representative output:
R1#
Interface IP-Address OK? Method Status Protocol
GigabitEthernet0/0 10.10.10.1 YES NVRAM up up
GigabitEthernet0/1 172.16.40.1 YES NVRAM up up
GigabitEthernet0/2 172.16.42.1 YES NVRAM up down
GigabitEthernet0/3 unassigned YES unset administratively down down
Loopback0 1.1.1.1 YES NVRAM up up
That’s the whole pattern: connect, send_command, read a string. Everything after this builds on it.
If your user lands in user EXEC mode, add "secret": "..." to the dictionary and call conn.enable() before the command.
Script 3: How do you loop over an inventory without one dead device killing the run?
Exam topics 1.2 (parsing YAML into Python data) and 4.8 (secret protection).
Script 2 talks to one box. Real work means a list of them, and on any list some device will be down. Without error handling, the first unreachable switch throws an exception and the rest never run.
This script reads inventory.yaml, adds the credentials from the environment, and catches the two failures you’ll actually see: a device that doesn’t answer and a login that’s refused.
import os
import yaml
from netmiko import ConnectHandler, NetmikoAuthenticationException, NetmikoTimeoutException
def load_inventory(path="inventory.yaml"):
with open(path) as f:
data = yaml.safe_load(f)
creds = {"username": os.environ["NET_USER"], "password": os.environ["NET_PASS"]}
return [{**data["defaults"], **creds, "host": d["host"]} for d in data["devices"]]
def get_version(device):
try:
with ConnectHandler(**device) as conn:
facts = conn.send_command("show version", use_textfsm=True)[0]
return f"{facts['hostname']:6} {facts['version']:12} up {facts['uptime']}"
except NetmikoTimeoutException:
return f"{device['host']:6} UNREACHABLE"
except NetmikoAuthenticationException:
return f"{device['host']:6} LOGIN FAILED"
if __name__ == "__main__":
for dev in load_inventory():
print(get_version(dev))
Representative output, with R2 switched off:
R1 15.9(3)M9 up 2 hours, 14 minutes
10.10.10.2 UNREACHABLE
SW1 15.9(3)M9 up 2 hours, 14 minutes
SW2 15.9(3)M9 up 2 hours, 14 minutes
R2 failed, and the loop kept going. We tested both failure paths, timeout and refused login, and each one printed its line and moved on.
Notice use_textfsm=True on show version. That’s the next script’s whole idea, and load_inventory() and get_version() get reused in scripts 4, 5 and 9.
Script 4: How do you parse show output with Netmiko and TextFSM?
Exam topics 1.2 (parsing to Python data structures) and 4.5 (construct a Python unit test).
Raw CLI output is a wall of text. Add use_textfsm=True to send_command and Netmiko runs it through a TextFSM template from ntc-templates, which comes installed with Netmiko. You get back a list of dictionaries instead of a string. That one argument is the whole Netmiko TextFSM trick.

use_textfsm=True runs raw show output through an ntc-templates parser and hands back one dictionary per row: interface, ip_address, status and proto.
For show ip interface brief, each dictionary has four keys: interface, ip_address, status and proto. This script uses them to find interfaces that are enabled but have no line protocol, the classic “cable’s in, nothing’s working” state.
from s03_inventory_versions import load_inventory
from netmiko import ConnectHandler
def find_problems(rows):
"""Interfaces that are enabled but have no line protocol."""
return [r["interface"] for r in rows if r["status"] == "up" and r["proto"] == "down"]
if __name__ == "__main__":
for dev in load_inventory():
with ConnectHandler(**dev) as conn:
rows = conn.send_command("show ip interface brief", use_textfsm=True)
for intf in find_problems(rows):
print(f"{dev['host']}: {intf} is up/down")
Representative output:
10.10.10.1: GigabitEthernet0/2 is up/down
Here’s a trap we hit while testing. Type the command in full, or at least as sh ip int br. With ntc-templates 9.3.0, sh ip int b matches the template for show ip interface, the long version, not the brief one. That template can’t read brief output, and Netmiko 4.8.0 raised a TextFSMError instead of falling back to plain text. Full commands avoid the whole problem.
One more detail. If your inventory says cisco_xe, Netmiko retries with the cisco_ios templates when no XE template matches. So IOS XE devices parse fine too.
Because find_problems() is a plain function, you can test it without a device. That’s exactly what exam topic 4.5 asks you to do:
from s04_interface_audit import find_problems
def test_flags_only_up_down():
rows = [
{"interface": "Gi0/1", "status": "up", "proto": "up"},
{"interface": "Gi0/2", "status": "up", "proto": "down"},
{"interface": "Gi0/3", "status": "administratively down", "proto": "down"},
]
assert find_problems(rows) == ["Gi0/2"]
Real output from pytest -q:
. [100%]
1 passed in 0.17s
The test proves a shut interface doesn’t get flagged by mistake. It’s a small thing, and exactly the habit that separates a script from a tool you trust.
Script 5: How do you back up every running-config into Git?
Exam topic 1.8 (common Git operations).
Pulling configs is easy. The useful part is keeping their history. This script saves one file per device, named after the hostname from the prompt, and commits the lot to Git. Because the file names never change, git log and git diff show you exactly what changed on which box and when.
import subprocess
from pathlib import Path
from s03_inventory_versions import load_inventory
from netmiko import ConnectHandler
backup_dir = Path("backups")
backup_dir.mkdir(exist_ok=True)
for dev in load_inventory():
with ConnectHandler(**dev) as conn:
hostname = conn.find_prompt().rstrip("#>")
config = conn.send_command("show running-config")
(backup_dir / f"{hostname}.cfg").write_text(config)
# One file per device, so git log and git diff show every change over time
subprocess.run(["git", "-C", str(backup_dir), "add", "."], check=True)
subprocess.run(["git", "-C", str(backup_dir), "commit", "-m", "nightly backup"], check=False)
Run git init backups once before the first run. On a fresh lab VM, also set git config --global user.name and user.email, or the commit fails with “Author identity unknown”. We hit that on a clean test machine. After that, every run is a commit. The commit step uses check=False on purpose, because Git returns an error when nothing changed, and a quiet night shouldn’t look like a failure.
On our test run the first commit held R1.cfg, R2.cfg, SW1.cfg and SW2.cfg. Schedule it with cron and you’ve got nightly config history for free. The same version-control habits carry into CI pipelines, which our DevOps Workbook builds from scratch.
Script 6: How do you generate configs from a Jinja2 template?
Exam topic 5.5 (principles of infrastructure as code). Needs no device.
Stop hand-writing config for every switch. Write the shape once as a Jinja2 template, keep the data in YAML, and let Python fill in the blanks. The template:
hostname {{ name }}
{% for vlan in vlans %}
vlan {{ vlan.id }}
name {{ vlan.name }}
{% endfor %}
interface {{ uplink }}
switchport mode trunk
switchport trunk allowed vlan {{ vlans | map(attribute='id') | join(',') }}
The data, switches.yaml:
vlans:
- {id: 10, name: USERS}
- {id: 20, name: VOICE}
- {id: 30, name: SERVERS}
- {id: 99, name: MGMT}
switches:
- {name: SW1, uplink: GigabitEthernet0/1}
- {name: SW2, uplink: GigabitEthernet0/1}
And the script that joins them:
from pathlib import Path
import yaml
from jinja2 import Environment, FileSystemLoader, StrictUndefined
env = Environment(loader=FileSystemLoader("templates"), trim_blocks=True,
lstrip_blocks=True, undefined=StrictUndefined)
template = env.get_template("access_switch.j2")
data = yaml.safe_load(open("switches.yaml"))
Path("configs").mkdir(exist_ok=True)
for sw in data["switches"]:
text = template.render(vlans=data["vlans"], **sw)
Path(f"configs/{sw['name']}.cfg").write_text(text)
print(f"--- {sw['name']} ---\n{text}")
Real output for SW1 (SW2 is identical apart from the hostname):
--- SW1 ---
hostname SW1
vlan 10
name USERS
vlan 20
name VOICE
vlan 30
name SERVERS
vlan 99
name MGMT
interface GigabitEthernet0/1
switchport mode trunk
switchport trunk allowed vlan 10,20,30,99
StrictUndefined is the setting worth stealing. Without it, a typo in a variable name renders as an empty string and you push a broken line. With it, the render fails loudly before anything leaves your laptop.
Need a new VLAN on every switch? Add one line to the YAML. That’s the point of infrastructure as code.
Script 7: How do you push config safely with Netmiko?
Job skill. Not named on the exam, but it’s the step everything above leads to.
send_command is for show commands. For config you want send_config_set (a list of lines) or send_config_from_file (a file, like the ones script 6 wrote). Both enter config mode, send the lines and exit.
The part most tutorials skip is error_pattern. By default, if IOS rejects a line, Netmiko just hands back the output with % Invalid input buried in it, and your script carries on. Pass a pattern and Netmiko raises ConfigInvalidException the moment a line is rejected.
import os
from netmiko import ConnectHandler, ConfigInvalidException
sw1 = {"device_type": "cisco_ios", "host": "10.10.10.11",
"username": os.environ["NET_USER"], "password": os.environ["NET_PASS"]}
with ConnectHandler(**sw1) as conn:
try:
output = conn.send_config_from_file(
"configs/SW1.cfg",
error_pattern=r"% Invalid|% Incomplete|% Ambiguous",
)
except ConfigInvalidException as err:
raise SystemExit(f"Rejected, startup-config not touched:\n{err}")
print(output)
print(conn.save_config()) # cisco_ios default: write mem
Representative tail of the output:
write mem
Building configuration...
[OK]
Be precise about what “safe” means here. Lines sent before the rejected one are already in the running-config. What the exception stops is save_config(), so a half-applied change never lands in startup-config. Reload the box and you’re back to the last good state. Script 8 shows you how to see exactly what went in.
Script 8: How do you see exactly what changed after a push?
Exam topic 5.12 (interpret a unified diff). Needs no device.
Capture the config before the change, capture it after, and diff the two. Python’s built-in difflib gives you the same unified diff format Git uses, and it’s the format the exam asks you to read.

A unified diff marks removed lines with – and added lines with +. Reading one is exam topic 5.12 on 200-901.
import difflib
import sys
def config_diff(before, after, name="device"):
return "".join(difflib.unified_diff(
before.splitlines(keepends=True), after.splitlines(keepends=True),
fromfile=f"{name}-before", tofile=f"{name}-after"))
if __name__ == "__main__":
before, after = (open(p).read() for p in sys.argv[1:3])
print(config_diff(before, after, "SW1") or "No change")
Real output, comparing SW1’s old config (VLANs 10 and 20 only) against the file script 6 rendered:
--- SW1-before
+++ SW1-after
@@ -3,6 +3,10 @@
name USERS
vlan 20
name VOICE
+vlan 30
+ name SERVERS
+vlan 99
+ name MGMT
interface GigabitEthernet0/1
switchport mode trunk
- switchport trunk allowed vlan 10,20
+ switchport trunk allowed vlan 10,20,30,99
Read the last two lines carefully, because they hide the most expensive lesson on this page. On IOS, switchport trunk allowed vlan 10,20,30,99 doesn’t add to the list. It replaces it. Cisco’s command reference says the add option “adds the defined list of VLANs to those currently set instead of replacing the list.”
So the template is the source of truth. Here that’s what you want. But if someone added VLAN 50 to that trunk by hand last month, and it isn’t in your YAML, this push quietly removes it. The diff is how you catch that before it’s an outage. The same “one place defines the VLAN list” thinking is why most networks leave VTP off.
Script 9: How do you run a Python script on 50 devices at once?
Job skill. Not on the exam, and the one that makes automation worth it at scale.
Script 3 visits devices one after another. Each SSH session spends most of its time waiting on the network and the device, not on your CPU. So threads help a lot. ThreadPoolExecutor from the standard library runs get_version on several devices at the same time:
import time
from concurrent.futures import ThreadPoolExecutor
from s03_inventory_versions import load_inventory, get_version
devices = load_inventory()
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
for line in pool.map(get_version, devices):
print(line)
print(f"{len(devices)} devices in {time.perf_counter() - start:.1f}s")
pool.map returns results in inventory order, so the output reads the same as script 3. It just arrives sooner.
How much sooner? We measured it. We ran the same get_version function against 50 simulated devices, each holding its session for exactly 2 seconds, at four worker counts:
| Workers | Measured run time | Predicted: ceil(50 ÷ workers) × 2 s |
|---|---|---|
| 1 | 100.4 s | 100 s |
| 5 | 20.1 s | 20 s |
| 10 | 10.1 s | 10 s |
| 20 | 6.1 s | 6 s |

Five workers, five sessions at once. With 50 devices and 2-second sessions, run time fell from 100.4 s (1 worker) to 20.1 s (5), 10.1 s (10) and 6.1 s (20).
The measurements land on the prediction to within half a second. That gives you a rule you can plan with: run time = rounds × session time, where rounds = devices ÷ workers, rounded up.
The rounding is the part people miss. Going from 10 workers to 20 cut the run from 10 s to 6 s, not to 5 s, because 50 devices across 20 workers still needs 3 rounds. At 25 workers it’s 2 rounds, so 4 s. So pick a worker count that divides your device count cleanly.
Two honest caveats. Real sessions aren’t all 2 seconds, so the slowest device in each round sets the pace. And more workers means more simultaneous logins to your AAA server, so start around 5 to 10 and raise it once you’ve watched it run.
Want a bigger project for these patterns? The free network automation BGP lab PDF builds a multi-AS BGP topology with Python and Netmiko.
Script 10: How do you read interfaces over RESTCONF instead of SSH?
Exam topics 2.9 (Python script that calls a REST API with requests), 3.8 (YANG, RESTCONF and NETCONF) and 5.10 (interpret a RESTCONF query).
Everything so far screen-scrapes the CLI. RESTCONF skips that. You ask IOS XE for data over HTTPS and get JSON back that’s already structured, laid out by a YANG model. This is the half of the exam most CLI people find new.

RESTCONF answers over HTTPS with JSON shaped by a YANG model, here ietf-interfaces. Exam topics 2.9, 3.8 and 5.10.
On the IOS XE device, Cisco’s RESTCONF guide turns it on with two lines. If your login comes from RADIUS or TACACS+, that user needs privilege 15:
restconf
ip http secure-server
Then the script reads every interface from the standard ietf-interfaces model:
import os
import requests
import urllib3
urllib3.disable_warnings() # lab only: self-signed certificate
url = "https://10.10.10.21/restconf/data/ietf-interfaces:interfaces"
headers = {"Accept": "application/yang-data+json"}
auth = (os.environ["NET_USER"], os.environ["NET_PASS"])
resp = requests.get(url, headers=headers, auth=auth, verify=False, timeout=10)
resp.raise_for_status()
for intf in resp.json()["ietf-interfaces:interfaces"]["interface"]:
ip = intf.get("ietf-ip:ipv4", {}).get("address", [{}])[0].get("ip", "no ip")
print(f"{intf['name']:22} enabled={intf['enabled']!s:5} {ip}")
Representative output:
GigabitEthernet1 enabled=True 10.10.10.21
GigabitEthernet2 enabled=False no ip
Loopback0 enabled=True 21.21.21.21
Look at the line that sets ip. An interface with no address has no address list to read, whether the ietf-ip:ipv4 object comes back empty or not at all. A plain intf["ietf-ip:ipv4"]["address"] would crash on GigabitEthernet2. The chained .get() calls handle both cases.
Two lab-only shortcuts to remove before this goes anywhere real: verify=False and the warning suppression. Both exist because lab routers use self-signed certificates.
Which of these scripts map to the CCNA Automation 200-901 exam?
Most of them, with one honest caveat. The official 200-901 CCNAAUTO v1.1 exam topics don’t name Netmiko anywhere. The exam tests the ideas around it: parsing, Git, APIs, testing, diffs and model-driven programmability. Netmiko is how you practise those ideas on real gear.
| Script | 200-901 topic | Domain (weight) |
|---|---|---|
| 1. Subnet planner | 6.2 IP addresses, routes, subnet mask / prefix | Network Fundamentals (15%) |
| 3. Inventory loop | 1.2 parsing YAML to Python data; 4.8 secret protection | Software Development (15%), App Deployment and Security (15%) |
| 4. TextFSM audit + test | 1.2 parsing; 4.5 construct a Python unit test | Software Development, App Deployment and Security |
| 5. Git backups | 1.8 Git operations | Software Development and Design (15%) |
| 6. Jinja2 configs | 5.5 infrastructure as code | Infrastructure and Automation (20%) |
| 8. Config diff | 5.12 interpret a unified diff | Infrastructure and Automation (20%) |
| 10. RESTCONF | 2.9 requests script; 3.8 YANG/RESTCONF; 5.10 interpret RESTCONF results | APIs (20%), Cisco Platforms (15%), Infrastructure and Automation (20%) |
| 2, 7, 9 | Job skills (SSH sessions, config push, scale), not named on the blueprint | n/a |
Some context on the name. Cisco rebranded the DevNet track as CCNA, CCNP and CCIE Automation on 2026-02-03, per its Learning blog. The exam code stayed 200-901. We compared the old DevNet Associate v1.1 topic PDF with the new CCNAAUTO one, and the six domain weights match. The main visible change is product names, with Catalyst Center replacing DNA Center. Cisco’s CCNA Automation page lists the exam at US$300 and 120 minutes.
For the career side of the same exam, which skills hiring managers screen for and what the roles pay, read our 200-901 skills guide. For every exam domain as hands-on labs, the CCNA Automation Workbook is built for exactly that.
Which Python networking libraries should you use: Netmiko, Paramiko, NAPALM, Nornir, Scrapli or Ansible?
They aren’t really rivals. Most of them sit at different layers, and several use each other. Here’s how the main Python networking libraries line up:
| Tool | What it is | Pick it when |
|---|---|---|
| Paramiko | Python’s SSHv2 library. Raw channels, you handle prompts and paging yourself | You’re automating a non-network device, or building your own library |
| Netmiko | Built on Paramiko. Handles prompts, paging, enable and config mode across 352 SSH device types | You want CLI automation now, with the commands you already know |
| NAPALM | Vendor-neutral layer. Getters like get_facts() and get_interfaces() return the same structure on every vendor, plus load_merge_candidate(), compare_config(), commit_config() and rollback() | You manage several vendors and want one data model and config diffs built in |
| Nornir | A pure-Python framework that handles inventory and runs tasks across devices in threads. Netmiko and NAPALM plug into it | Script 9 has grown up. You want inventory, filtering and concurrency handled for you |
| Scrapli | A “fast, flexible, sync/async” screen-scraping client for network devices | You need asyncio, or speed at very large scale |
| Ansible | YAML playbooks, not Python you write. Network modules ship in collections such as cisco.ios | Your team prefers declarative playbooks over code (exam topic 5.6 names it) |
The decision rule: start with Netmiko, because it’s the shortest path from CLI knowledge to a working script. Move to NAPALM when you need several vendors to look the same, and to Nornir when your scripts start rebuilding inventory and threading by hand. Paramiko is underneath Netmiko already, so you rarely need it directly.
Is Python good for networking, and how should you learn it?
Yes, for most engineering roles now. Python is the language the main open-source network automation libraries are written in, and Cisco tests it directly: exam topics 2.9 and 3.1 both ask you to construct Python scripts.
But here’s the thing. You don’t need to become a software developer. Python programming for network engineers is a narrow slice: variables, lists, dictionaries, loops, functions, reading files, and handling exceptions. The 10 scripts above use almost nothing beyond that, plus a few libraries. That’s what networking automation with Python looks like day to day.
So learn it in the order this page runs:
- Offline first. Scripts 1, 6 and 8 need no device. Get comfortable with dictionaries, loops and files there.
- One device. Script 2. One router, one command, one working session.
- Many devices, safely. Scripts 3, 4 and 7. Inventory, parsing and error handling.
- History and scale. Scripts 5 and 9.
- APIs. Script 10, then the rest of the RESTCONF and NETCONF topics.
Python for network engineers: frequently asked questions
What is Netmiko?
Netmiko is an open-source Python library, MIT licensed, that simplifies SSH connections to network devices. It’s built on Paramiko and adds network-specific handling: prompt detection, paging, enable mode and config mode. Version 4.8.0 supports 352 SSH device types, including cisco_ios, cisco_nxos, arista_eos and juniper_junos.
How do I install Netmiko?
Run pip install netmiko inside a virtual environment. Netmiko 4.8.0 needs Python 3.10 or newer, and it installs Paramiko, TextFSM and ntc-templates for you, so use_textfsm=True works with no extra setup. Check the install with python -c "import netmiko; print(netmiko.__version__)".
What’s the difference between Netmiko and Paramiko?
Paramiko is a general SSH library. It opens a channel and leaves the rest to you. Netmiko is built on top of Paramiko and handles the parts specific to network gear, like waiting for the right prompt, turning off paging and entering config mode. For Cisco devices, Netmiko saves you writing all of that yourself.
Is Netmiko on the CCNA Automation exam?
No. The 200-901 CCNAAUTO v1.1 exam topics don’t name Netmiko. They do cover what Netmiko scripts practise: parsing data into Python structures (1.2), Git (1.8), Python unit tests (4.5), device-level interfaces (3.6) and interpreting a unified diff (5.12). They also require Python scripts that call REST APIs with requests (2.9).
What Python libraries should network engineers learn first?
Start with the standard library’s ipaddress, then Netmiko for SSH, TextFSM (through Netmiko’s use_textfsm) for parsing, Jinja2 and PyYAML for templates and data, and requests for REST APIs. Together they cover every script on this page. Add NAPALM or Nornir once you’re managing many devices or several vendors.
Bottom line
Python for network engineers isn’t a computer science degree. It’s 10 patterns: plan, connect, loop, parse, back up, template, push, diff, scale and call an API. Run them in a lab in that order and you’ll have practised 11 of the 200-901 exam topics along the way.
- Start offline. Scripts 1, 6 and 8 need no gear.
- Fail loudly. Catch timeouts, use
error_pattern, setStrictUndefined. - Diff before you trust. The trunk VLAN list taught us that one.
- Plan concurrency. Run time is devices ÷ workers, rounded up, times session time.
Ready for the full set? The CCNA Automation Workbook turns every exam domain into labs you type out yourself, from Python basics to RESTCONF. And if you still need the routers, these CCNA labs you can build free in EVE-NG give your scripts something to talk to.