Network Automation with Python: A Complete Practical Guide for Network Engineers (2026)
Keywords: Network Automation Python • Python Network Scripts • Netmiko Tutorial • NAPALM Python • Nornir Framework • NETCONF Python • Network DevOps • Cisco Automation Python • Ansible Python Network • REST API Network Automation • Python Paramiko • Jinja2 Network Templates • Network as Code • Infrastructure Automation Python
The network engineer who can only configure devices through a CLI is not obsolete yet — but they will be within a decade. The one who can write a 50-line Python script that configures 200 switches in 3 minutes, validates the result, and opens a ticket if anything fails is the one organizations are actively hiring. This guide teaches exactly that, with working code, real libraries, and honest assessments of when each tool makes sense.
May 2026 | ⏱ 35 min read | Python 3.11+ • Netmiko • NAPALM • Nornir • NETCONF • REST APIs | ⚙ Network Engineers • NetDevOps • CCNP/CCIE Level
What This Guide Covers
Python environment setup → SSH automation with Netmiko → Multi-vendor abstraction with NAPALM → Parallel execution with Nornir → NETCONF & RESTCONF → REST API calls → Jinja2 templates → Real-world scripts for config backup, VLAN deployment, compliance checking, and change management → CI/CD pipelines for network automation
Sections in This Guide
|
1. Why Python for Network Automation 2. Environment Setup & Essential Libraries 3. SSH Automation with Netmiko 4. Multi-Vendor Abstraction with NAPALM 5. Parallel Execution with Nornir 6. NETCONF and RESTCONF with Python 7. REST API Automation |
8. Jinja2 Templates for Config Generation 9. Working with YAML and JSON 10. Real-World Automation Scripts 11. Error Handling and Logging 12. CI/CD Pipelines for Network Automation 13. Best Practices 14. FAQ |
1. Why Python for Network Automation
Python isn’t the only language that can automate networks. Go is faster. Rust is safer. JavaScript runs everywhere. But Python dominates network automation for a specific combination of reasons: the library ecosystem (Netmiko, NAPALM, Nornir, ncclient, requests) is richer than any other language for networking, the syntax is close enough to human English that network engineers with no programming background can read and contribute to scripts, and vendors like Cisco, Arista, Juniper, and Fortinet have all built first-party Python SDKs or REST APIs that Python clients consume naturally.
The CCNA, CCNP, and CCIE exam blueprints now include Python basics. Vendor certifications for Arista (ACE) and Juniper (JNCIS-DevOps) are Python-centric. The industry has settled on Python for this domain the same way it settled on SQL for database queries.
| Task | Manual Time (50 devices) | Python Automated |
| Config backup | 45–90 minutes | 60–90 seconds |
| VLAN deployment across all switches | 2–4 hours | 3–5 minutes |
| NTP compliance check | 1–2 hours + manual report | 30 seconds + auto report |
| Software upgrade (check + push + verify) | Full maintenance window + 2 engineers | Automated with rollback logic |
The honest starting point: Network automation doesn’t replace network knowledge. A Python script that pushes wrong configurations faster than you could type them manually is worse than doing it slowly by hand. You still need to understand routing protocols, VLANs, and security policy. What automation removes is the repetitive execution of decisions you’ve already made correctly.
2. Environment Setup & Essential Libraries
Use Python 3.11 or 3.12. Virtual environments keep project dependencies isolated — don’t install automation libraries into your system Python. Every project gets its own venv.
# Set up a virtual environment for network automation
python3 -m venv netauto-env source netauto-env/bin/activate # Linux / macOS netauto-env\Scripts\activate # Windows pip install netmiko napalm nornir nornir-netmiko nornir-napalm \ ncclient requests paramiko jinja2 pyyaml rich \ nornir-utils pynetbox textfsm ttp genie pyats
Core Network Automation Libraries
| Library | Purpose | Best For | Vendor Support |
| Netmiko | SSH connection management | CLI-based automation on any SSH device | 100+ vendors/device types |
| NAPALM | Multi-vendor abstraction layer | Vendor-agnostic get/set operations | Cisco IOS/NX-OS/IOS-XR, EOS, JunOS, FortiOS |
| Nornir | Automation framework with threading | Running tasks on many devices in parallel | Vendor-agnostic (uses plugins) |
| ncclient | NETCONF over SSH | Structured config via YANG models | Cisco IOS-XE, NX-OS, Juniper, Nokia |
| requests | HTTP REST API calls | RESTCONF, vendor APIs, NetBox | Any REST API |
| Jinja2 | Template rendering | Generating device configs from templates | Vendor-agnostic (text templates) |
requirements.txt for your project: Always pin library versions in production scripts. pip freeze > requirements.txt captures all installed versions. pip install -r requirements.txt recreates the exact environment on another machine. Unpinned dependencies mean a library update on a colleague’s machine can silently break scripts that worked on yours.
3. SSH Automation with Netmiko
Netmiko (created by Kirk Byers) is the most widely-used Python library for SSH-based network device automation. It handles the SSH connection, login prompts, enable mode, pagination, and device-specific quirks so you don’t have to. Under the hood, it uses Paramiko (the Python SSH library) but abstracts away all the device-specific differences between vendors.
Basic Connection and Command Execution
# netmiko_basic.py — Connect to a Cisco IOS device and run show commands
from netmiko import ConnectHandler # Device connection dictionary cisco_device = { "device_type": "cisco_ios", # see Netmiko docs for full list "host": "192.168.1.1", "username": "admin", "password": "SecurePass123", "secret": "EnableSecret456", # enable password "port": 22, } # Open connection — use context manager to auto-disconnect with ConnectHandler(**cisco_device) as net_connect: net_connect.enable() # enter enable mode # Run a show command and get output as string output = net_connect.send_command("show version") print(output) # Get interface status table interfaces = net_connect.send_command( "show ip interface brief", use_textfsm=True # parse output into structured list of dicts ) for intf in interfaces: print(f"{intf['intf']:20} {intf['ipaddr']:16} {intf['status']}")
Pushing Configuration with Netmiko
# Push configuration commands to a device
from netmiko import ConnectHandler config_commands = [ "interface GigabitEthernet0/1", "description UPLINK-TO-CORE", "ip address 10.0.1.2 255.255.255.252", "no shutdown", "exit", "ip route 0.0.0.0 0.0.0.0 10.0.1.1", "ntp server 216.239.35.0", ] with ConnectHandler(**cisco_device) as net_connect: net_connect.enable() # send_config_set enters config mode, sends commands, exits output = net_connect.send_config_set(config_commands) print(output) # Save the configuration net_connect.save_config() print("Configuration saved.")
Running Against Multiple Devices
# Loop through multiple devices — backup all configs
from netmiko import ConnectHandler from datetime import datetime import os devices = [ {"device_type": "cisco_ios", "host": "10.0.0.1", "username": "admin", "password": "Cisco123!"}, {"device_type": "cisco_ios", "host": "10.0.0.2", "username": "admin", "password": "Cisco123!"}, {"device_type": "arista_eos", "host": "10.0.0.3", "username": "admin", "password": "Arista123!"}, ] timestamp = datetime.now().strftime("%Y%m%d_%H%M") os.makedirs("backups", exist_ok=True) for device in devices: try: with ConnectHandler(**device) as conn: conn.enable() config = conn.send_command("show running-config") filename = f"backups/{device['host']}_{timestamp}.cfg" with open(filename, "w") as f: f.write(config) print(f"✓ {device['host']} backup saved → {filename}") except Exception as e: print(f"✗ {device['host']} FAILED: {e}")
Common Netmiko Device Types
| Device Type String | Device |
| cisco_ios | Cisco IOS, IOS-XE (ISR, Catalyst) |
| cisco_nxos | Cisco NX-OS (Nexus switches) |
| cisco_xr | Cisco IOS-XR (ASR 9000, NCS) |
| arista_eos | Arista EOS |
| juniper_junos | Juniper JunOS |
| fortinet | Fortinet FortiOS |
| linux | Linux servers (SSH) |
TextFSM and NTC-templates: The use_textfsm=True parameter parses unstructured CLI output into Python dictionaries using NTC-templates (a community library of TextFSM templates). Before using it, run pip install ntc-templates and verify a template exists for your command (ntc-templates/templates/ on GitHub). If no template exists for your exact command, write one or use TTP (Template Text Parser) which has a more flexible syntax.
4. Multi-Vendor Abstraction with NAPALM
NAPALM (Network Automation and Programmability Abstraction Layer with Multivendor support) provides a unified API across multiple vendor operating systems. Instead of writing vendor-specific commands for each platform, you call get_interfaces() and get the same data structure back whether the device is running IOS, NX-OS, EOS, or JunOS.
NAPALM uses getters (read operations that return structured data) and setters (configuration loading with diff-before-commit and rollback support). The rollback capability is particularly valuable — NAPALM can show you the diff between current and desired config before committing, and roll back automatically if verification fails.
NAPALM Getters
# napalm_getters.py — Read structured data from any supported device
from napalm import get_network_driver import json # Supported drivers: ios, iosxr, nxos_ssh, eos, junos, fortios driver = get_network_driver("ios") device = driver( hostname="192.168.1.1", username="admin", password="SecurePass123", optional_args={"secret": "EnableSecret456"} ) device.open() # Get device facts (hostname, FQDN, vendor, model, OS version, uptime, serial) facts = device.get_facts() print(f"Hostname: {facts['hostname']}") print(f"Model: {facts['model']}") print(f"OS: {facts['os_version']}") print(f"Uptime: {facts['uptime']} seconds") # Get interface details interfaces = device.get_interfaces() for name, data in interfaces.items(): status = "UP" if data["is_up"] else "DOWN" print(f" {name:25} {status:6} speed={data['speed']} Mbps") # Get BGP neighbors bgp = device.get_bgp_neighbors() print(json.dumps(bgp, indent=2)) # Get environment (CPU, memory, temperature, fans) env = device.get_environment() print(f"CPU: {env['cpu'][0]['%usage']}% RAM: {env['memory']['used_ram']} bytes") device.close()
Available NAPALM Getters
| Getter Method | Returns |
| get_facts() | Hostname, FQDN, vendor, model, serial numbers, OS version, uptime, interface list |
| get_interfaces() | Interface status, speed, MTU, MAC, description per interface |
| get_interfaces_ip() | IP addresses and prefix lengths per interface |
| get_bgp_neighbors() | BGP peer details, ASN, state, prefixes received/sent |
| get_route_to(destination) | Best path and all routes to a specific prefix |
| get_arp_table() | ARP table entries (IP, MAC, interface, age) |
| get_mac_address_table() | MAC table (MAC, VLAN, interface, type) |
| get_environment() | CPU/memory/temperature/fan/PSU status |
NAPALM Configuration Loading with Diff and Rollback
# napalm_config.py — Load config with diff-before-commit
from napalm import get_network_driver driver = get_network_driver("eos") # Arista EOS example device = driver("192.168.2.1", "admin", "AristaPass") device.open() # Load candidate configuration from file device.load_merge_candidate(filename="configs/switch01_new.cfg") # Show the diff between current and candidate config diff = device.compare_config() if diff: print("Changes to be applied:") print(diff) confirm = input("Apply these changes? [y/N]: ") if confirm.lower() == "y": device.commit_config() print("✓ Configuration committed.") else: device.discard_config() # abandon candidate print("✗ Changes discarded.") else: print("No changes detected — device already in desired state.") device.discard_config() device.close()
5. Parallel Execution with Nornir
Nornir is a pure Python automation framework. Where Ansible is a configuration management tool that happens to work on networks, Nornir is built specifically for network automation. The key difference: Nornir runs tasks against all devices simultaneously using Python threads. Connecting to and configuring 50 switches takes the same time as configuring 1, if your thread pool is large enough.
Nornir Inventory Files
Nornir reads from hosts.yaml, groups.yaml, and defaults.yaml. Define your entire network inventory in these files.
# hosts.yaml
sw-access-01: hostname: 10.0.1.11 groups: - cisco_access data: site: HQ role: access sw-access-02: hostname: 10.0.1.12 groups: - cisco_access data: site: HQ role: access rtr-core-01: hostname: 10.0.1.1 groups: - cisco_routers data: site: HQ role: core_router
# groups.yaml
cisco_access: platform: ios connection_options: netmiko: extras: secret: "EnableSecret456" cisco_routers: platform: ios connection_options: netmiko: extras: secret: "EnableSecret456"
# defaults.yaml
username: admin password: NetworkPass123 port: 22
Nornir Task Execution
# nornir_tasks.py — Run tasks in parallel across all devices
from nornir import InitNornir from nornir_netmiko.tasks import netmiko_send_command, netmiko_send_config from nornir_utils.plugins.functions import print_result # Initialize Nornir from config file nr = InitNornir(config_file="config.yaml") # Filter to only access switches access_switches = nr.filter(groups=["cisco_access"]) # Run 'show version' on all access switches in parallel result = access_switches.run( task=netmiko_send_command, command_string="show version", name="Get device version" ) print_result(result) # Define a custom task function def deploy_ntp(task): """Configure NTP servers on all devices.""" commands = [ "ntp server 216.239.35.0 prefer", "ntp server 216.239.35.4", "service timestamps log datetime msec", ] result = task.run( task=netmiko_send_config, config_commands=commands ) task.run( task=netmiko_send_command, command_string="write memory" ) return result # Run custom task on all devices simultaneously result = nr.run(task=deploy_ntp, name="Deploy NTP") print_result(result) # Check for failures failed_devices = [host for host, multi in result.items() if multi.failed] if failed_devices: print(f"\n⚠ FAILED: {failed_devices}") else: print(f"\n✓ All {len(nr.inventory.hosts)} devices updated successfully")
Nornir vs Ansible for network tasks: Nornir runs entirely in Python — debugging is straightforward, you can use Python’s full ecosystem (logging, type hints, unit tests), and there’s no YAML DSL to fight with. Ansible has a larger community and many pre-built roles. The choice usually comes down to team background: engineers comfortable with Python prefer Nornir; teams with more ops/DevOps background prefer Ansible. Both are production-grade; neither is wrong.
6. NETCONF and RESTCONF with Python
SSH CLI automation works, but it has a fundamental flaw: CLI output is text, and parsing text is fragile. Change the IOS version, the output format changes, your regex breaks. NETCONF and RESTCONF solve this by returning structured data (XML or JSON) based on YANG models. The data has a defined schema, so version upgrades don’t break your parser as long as the YANG model is backward compatible.
NETCONF with ncclient
# netconf_example.py — NETCONF on Cisco IOS-XE (enable with: netconf-yang)
from ncclient import manager import xmltodict import json # Connect via NETCONF (port 830) with manager.connect( host="192.168.1.1", port=830, username="admin", password="SecurePass123", hostkey_verify=False, device_params={"name": "iosxe"} ) as m: # Get all interfaces using YANG filter (ietf-interfaces model) interface_filter = """ <filter> <interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces"> <interface/> </interfaces> </filter> """ response = m.get_config(source="running", filter=interface_filter) # Convert XML to Python dict data = xmltodict.parse(response.xml) interfaces = data["rpc-reply"]["data"]["interfaces"]["interface"] for intf in interfaces: print(f"Interface: {intf['name']:25} Type: {intf.get('type','N/A')}") # Configure an interface via NETCONF edit-config config_payload = """ <config> <interfaces xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces"> <interface> <name>GigabitEthernet2</name> <description>PYTHON-CONFIGURED</description> <enabled>true</enabled> <ipv4 xmlns="urn:ietf:params:xml:ns:yang:ietf-ip"> <address> <ip>10.1.1.1</ip> <prefix-length>24</prefix-length> </address> </ipv4> </interface> </interfaces> </config> """ m.edit_config(target="running", config=config_payload) print("✓ Interface configured via NETCONF")
RESTCONF with Python requests
# restconf_example.py — RESTCONF on IOS-XE (enable: ip http secure-server + restconf)
import requests import json requests.packages.urllib3.disable_warnings() # suppress self-signed cert warnings BASE_URL = "https://192.168.1.1:443/restconf" HEADERS = { "Content-Type": "application/yang-data+json", "Accept": "application/yang-data+json", } AUTH = ("admin", "SecurePass123") # GET — retrieve interface configuration url = f"{BASE_URL}/data/ietf-interfaces:interfaces" response = requests.get(url, headers=HEADERS, auth=AUTH, verify=False) response.raise_for_status() data = response.json() print(json.dumps(data, indent=2)) # GET — BGP configuration bgp_url = f"{BASE_URL}/data/Cisco-IOS-XE-bgp-oper:bgp-state-data" bgp_resp = requests.get(bgp_url, headers=HEADERS, auth=AUTH, verify=False) print(json.dumps(bgp_resp.json(), indent=2)) # PATCH — update interface description patch_url = f"{BASE_URL}/data/ietf-interfaces:interfaces/interface=GigabitEthernet1" payload = { "ietf-interfaces:interface": { "name": "GigabitEthernet1", "description": "WAN-LINK-CONFIGURED-BY-PYTHON", "enabled": True } } patch_resp = requests.patch( patch_url, headers=HEADERS, auth=AUTH, data=json.dumps(payload), verify=False ) print(f"PATCH status: {patch_resp.status_code}") # 204 = success, no body
7. REST API Automation
Many modern network management platforms — Cisco DNA Center (Catalyst Center), Meraki, FortiManager, Palo Alto Panorama, NetBox — expose REST APIs. These are often the fastest path to automation for platform-level operations that NETCONF doesn’t cover.
Cisco DNA Center (Catalyst Center) API
# dnac_api.py — Interact with Cisco Catalyst Center REST API
import requests import json DNAC_HOST = "https://sandboxdnac.cisco.com" USERNAME = "devnetuser" PASSWORD = "Cisco123!" def get_auth_token(): """Get JWT token from DNA Center auth endpoint.""" url = f"{DNAC_HOST}/dna/system/api/v1/auth/token" response = requests.post(url, auth=(USERNAME, PASSWORD), verify=False) response.raise_for_status() return response.json()["Token"] def get_devices(token): """Get list of all network devices managed by DNA Center.""" headers = {"x-auth-token": token, "Content-Type": "application/json"} url = f"{DNAC_HOST}/dna/intent/api/v1/network-device" response = requests.get(url, headers=headers, verify=False) return response.json()["response"] def run_command_on_device(token, device_id, command): """Run a CLI command on a specific device and get output.""" headers = {"x-auth-token": token, "Content-Type": "application/json"} url = f"{DNAC_HOST}/dna/intent/api/v1/network-device-poller/cli/read-request" payload = { "commands": [command], "deviceUuids": [device_id] } response = requests.post(url, headers=headers, json=payload, verify=False) return response.json() # Main execution token = get_auth_token() devices = get_devices(token) for device in devices[:5]: # first 5 devices print(f"{device['hostname']:30} {device['platformId']:20} {device['softwareVersion']}")
NetBox REST API for IPAM and Inventory
# netbox_api.py — Query NetBox IPAM via pynetbox or raw requests
import pynetbox # Connect to NetBox nb = pynetbox.api( "https://netbox.example.com", token="your-netbox-api-token" ) # Get all active network devices devices = nb.dcim.devices.filter(status="active", site="hq") for device in devices: print(f"{device.name:30} {str(device.device_type):25} {device.primary_ip}") # Build Nornir inventory from NetBox automatically nornir_hosts = {} for device in nb.dcim.devices.filter(status="active"): if device.primary_ip: nornir_hosts[device.name] = { "hostname": str(device.primary_ip).split("/")[0], "platform": device.platform.slug if device.platform else "ios", "data": { "site": device.site.name, "role": device.device_role.name, } } print(f"Built inventory: {len(nornir_hosts)} devices")
Cisco DevNet Sandbox for practicing: Cisco provides free, always-on sandboxes at devnetsandbox.cisco.com where you can test against real IOS-XE, NX-OS, ACI, and DNA Center environments without needing lab hardware. The Always-On IOS-XE sandbox at sandbox-iosxe-latest-1.cisco.com is accessible via SSH and NETCONF/RESTCONF. Start your practice there before touching production.
8. Jinja2 Templates for Configuration Generation
Jinja2 is a Python templating engine that generates text (in this case, network device configurations) by combining a template with variable data. Instead of editing 50 switch configurations individually, you maintain one template and a data file per switch. Change the template once; regenerate all 50 configurations.
Jinja2 Template for Cisco Switch Baseline
# templates/switch_baseline.j2
hostname {{ hostname }} ip domain-name {{ domain }} {# NTP servers loop #} {% for ntp in ntp_servers %} ntp server {{ ntp }}{% if loop.first %} prefer{% endif %} {% endfor %} {# Configure VLANs #} {% for vlan in vlans %} vlan {{ vlan.id }} name {{ vlan.name }} {% endfor %} {# Configure interfaces #} {% for interface in interfaces %} interface {{ interface.name }} description {{ interface.description }} {% if interface.type == "access" %} switchport mode access switchport access vlan {{ interface.vlan }} spanning-tree portfast {% elif interface.type == "trunk" %} switchport mode trunk switchport trunk allowed vlan {% for v in interface.vlans %}{{ v }}{% if not loop.last %},{% endif %}{% endfor %} {% elif interface.type == "routed" %} no switchport ip address {{ interface.ip }} {{ interface.mask }} {% endif %} no shutdown ! {% endfor %} {# Management SVI #} interface Vlan{{ mgmt_vlan }} ip address {{ mgmt_ip }} {{ mgmt_mask }} no shutdown ! ip default-gateway {{ mgmt_gateway }} {# SSH hardening #} crypto key generate rsa modulus 4096 ip ssh version 2 ip ssh authentication-retries 3 line vty 0 15 transport input ssh login local !
# jinja2_render.py — Generate config from template + YAML data
from jinja2 import Environment, FileSystemLoader import yaml import os # Set up Jinja2 environment env = Environment( loader=FileSystemLoader("templates"), trim_blocks=True, lstrip_blocks=True ) template = env.get_template("switch_baseline.j2") # Load per-device data from YAML file with open("data/sw-access-01.yaml") as f: device_data = yaml.safe_load(f) # Render the template with device-specific data rendered_config = template.render(**device_data) # Save rendered configuration os.makedirs("rendered_configs", exist_ok=True) output_file = f"rendered_configs/{device_data['hostname']}.cfg" with open(output_file, "w") as f: f.write(rendered_config) print(f"✓ Config rendered → {output_file}") print(rendered_config)
# data/sw-access-01.yaml
hostname: sw-access-01 domain: corp.local mgmt_vlan: 99 mgmt_ip: 192.168.99.11 mgmt_mask: 255.255.255.0 mgmt_gateway: 192.168.99.1 ntp_servers: - 216.239.35.0 - 216.239.35.4 vlans: - id: 10 name: Corporate-Users - id: 20 name: Voice - id: 99 name: Management - id: 999 name: Native-Unused interfaces: - name: GigabitEthernet0/1 description: "PC-Port-Cubicle-101" type: access vlan: 10 - name: GigabitEthernet0/2 description: "IP-Phone-Cubicle-101" type: access vlan: 20 - name: GigabitEthernet0/24 description: "UPLINK-TO-DISTRIBUTION" type: trunk vlans: [10, 20, 99]
9. Working with YAML and JSON in Network Automation
YAML is the standard format for network automation data: device inventories, variable files for templates, and configuration as data. JSON is the return format from most REST APIs and RESTCONF. You need to be comfortable reading and writing both.
# yaml_json_basics.py — Essential operations you'll use constantly
import yaml import json from pathlib import Path # --- YAML operations --- # Read YAML file → Python dict with open("devices.yaml") as f: devices = yaml.safe_load(f) # use safe_load, never load() # Read all YAML files in a directory (multi-device inventory) device_data = {} for yaml_file in Path("data/").glob("*.yaml"): with open(yaml_file) as f: data = yaml.safe_load(f) device_data[data["hostname"]] = data # Write Python dict → YAML report = {"status": "compliant", "devices": 50, "failed": []} with open("report.yaml", "w") as f: yaml.dump(report, f, default_flow_style=False) # --- JSON operations --- # Parse JSON API response json_string = '{"hostname": "rtr-01", "version": "17.9.4"}' data = json.loads(json_string) print(data["hostname"]) # Pretty-print nested JSON/dict print(json.dumps(data, indent=2, sort_keys=True)) # Save JSON to file with open("inventory.json", "w") as f: json.dump(data, f, indent=2) # Convert between YAML and JSON (common in automation pipelines) with open("devices.yaml") as f: yaml_data = yaml.safe_load(f) json_output = json.dumps(yaml_data, indent=2) print(json_output)
Validating YAML structure with Pydantic: If your automation reads YAML device data and passes it to templates, a typo in the YAML (wrong key name, wrong data type) causes confusing runtime errors. Use Pydantic (Python library for data validation) to define a schema for your device data and validate YAML files against it before processing. This catches errors at the data level rather than partway through a 50-device deployment.
10. Real-World Automation Scripts
Script 1: Network Compliance Checker
Checks all devices against a security baseline (NTP configured, SSH version 2, no Telnet, correct SNMP version) and generates a CSV report.
# compliance_checker.py
from nornir import InitNornir from nornir_netmiko.tasks import netmiko_send_command import csv from datetime import datetime # Compliance checks — each returns True if compliant CHECKS = { "NTP_configured": lambda output: "synchronized" in output.lower(), "SSH_v2_only": lambda output: "version 2" in output.lower(), "No_Telnet": lambda output: "transport input telnet" not in output.lower(), "SNMPv3_enabled": lambda output: "snmp-server user" in output.lower(), } COMMANDS = { "NTP_configured": "show ntp status", "SSH_v2_only": "show ip ssh", "No_Telnet": "show running-config | include transport input", "SNMPv3_enabled": "show running-config | include snmp-server user", } def compliance_check(task): results = {"host": task.host.name, "ip": str(task.host.hostname)} for check_name, command in COMMANDS.items(): output = task.run(netmiko_send_command, command_string=command)[0].result results[check_name] = "PASS" if CHECKS[check_name](output) else "FAIL" task.host["compliance"] = results nr = InitNornir(config_file="config.yaml") nr.run(task=compliance_check) # Write CSV report timestamp = datetime.now().strftime("%Y%m%d_%H%M") report_file = f"compliance_report_{timestamp}.csv" fieldnames = ["host", "ip"] + list(CHECKS.keys()) with open(report_file, "w", newline="") as csvfile: writer = csv.DictWriter(csvfile, fieldnames=fieldnames) writer.writeheader() for host_obj in nr.inventory.hosts.values(): if "compliance" in host_obj: writer.writerow(host_obj["compliance"]) print(f"✓ Report saved: {report_file}")
Script 2: Automated VLAN Deployment
# vlan_deploy.py — Deploy new VLANs to all access switches
from nornir import InitNornir from nornir_netmiko.tasks import netmiko_send_command, netmiko_send_config from nornir_utils.plugins.functions import print_result import yaml # Load VLAN definition from YAML with open("vlan_deploy.yaml") as f: vlan_config = yaml.safe_load(f) def deploy_vlans(task, vlans): """Deploy VLANs to a device and add to trunk ports.""" commands = [] # Create VLANs for vlan in vlans: commands.extend([ f"vlan {vlan['id']}", f"name {vlan['name']}", "exit", ]) # Add VLANs to all trunk ports existing_trunks = task.run( netmiko_send_command, command_string="show interfaces trunk | include Gi|Te" )[0].result for line in existing_trunks.splitlines(): if line.strip(): interface = line.split()[0] vlan_ids = ",".join([str(v["id"]) for v in vlans]) commands.extend([ f"interface {interface}", f"switchport trunk allowed vlan add {vlan_ids}", "exit", ]) task.run(netmiko_send_config, config_commands=commands) task.run(netmiko_send_command, command_string="write memory") nr = InitNornir(config_file="config.yaml") access_switches = nr.filter(groups=["cisco_access"]) result = access_switches.run( task=deploy_vlans, vlans=vlan_config["vlans"], name="Deploy VLANs" ) print_result(result)
Script 3: Interface Down Alerter
# interface_monitor.py — Check for down interfaces across all devices
from napalm import get_network_driver import yaml import smtplib from email.mime.text import MIMEText with open("devices.yaml") as f: devices = yaml.safe_load(f)["devices"] down_interfaces = [] for device in devices: driver = get_network_driver(device["platform"]) d = driver(device["host"], device["username"], device["password"]) try: d.open() interfaces = d.get_interfaces() for name, data in interfaces.items(): if data["is_enabled"] and not data["is_up"]: down_interfaces.append({ "device": device["host"], "interface": name, "desc": data["description"] }) d.close() except Exception as e: print(f"Could not check {device['host']}: {e}") if down_interfaces: body = "Down interfaces detected:\n\n" for item in down_interfaces: body += f" {item['device']:20} {item['interface']:25} {item['desc']}\n" print(body) # Send email alert (configure SMTP server details)
11. Error Handling and Logging
A script that crashes with an unhandled exception mid-deployment has pushed partial configuration to some devices and none to others — the worst possible state. Proper error handling ensures partial failures are caught, logged, and reported without affecting other devices. Logging to a file gives you a record of what happened when something goes wrong three days later.
# logging_setup.py — Production-grade logging for automation scripts
import logging import sys from datetime import datetime from netmiko.exceptions import ( NetmikoTimeoutException, NetmikoAuthenticationException, NetmikoBaseException ) # Set up structured logging to file + console timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") log_file = f"logs/automation_{timestamp}.log" logging.basicConfig( level=logging.INFO, format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S", handlers=[ logging.FileHandler(log_file), logging.StreamHandler(sys.stdout) ] ) logger = logging.getLogger("netauto") def safe_connect_and_run(device_dict, commands): """Connect and run commands with full exception handling.""" host = device_dict["host"] try: from netmiko import ConnectHandler with ConnectHandler(**device_dict) as conn: conn.enable() output = conn.send_config_set(commands) conn.save_config() logger.info(f"SUCCESS | {host} | {len(commands)} commands applied") return {"host": host, "status": "success", "output": output} except NetmikoAuthenticationException: logger.error(f"AUTH FAILED | {host} | Check credentials") return {"host": host, "status": "auth_failed"} except NetmikoTimeoutException: logger.error(f"TIMEOUT | {host} | Device unreachable or SSH not enabled") return {"host": host, "status": "timeout"} except NetmikoBaseException as e: logger.error(f"NETMIKO ERROR | {host} | {str(e)}") return {"host": host, "status": "error", "detail": str(e)} except Exception as e: logger.critical(f"UNEXPECTED | {host} | {type(e).__name__}: {str(e)}") return {"host": host, "status": "unexpected_error"}
12. CI/CD Pipelines for Network Automation
A CI/CD pipeline for network automation runs your scripts through a validation and testing process before they reach production devices. Every change is a Git commit. The pipeline runs automatically on every commit: syntax check, lint, unit tests in a virtual lab, then deploy to production after a peer review.
| Pipeline Stage | What Runs | Tools |
| 1. Lint & Syntax | Python syntax check, YAML validation, import order, code style | flake8, pylint, yamllint, black |
| 2. Unit Tests | Test template rendering, data validation, function logic with mocked device responses | pytest, unittest.mock, pytest-netmiko |
| 3. Lab Test | Run against virtual devices (Cisco CML, GNS3, Containerlab) to validate real execution | Containerlab, Cisco CML, Eve-NG |
| 4. Production Deploy | Push to production after peer review and lab test pass; automated or manual approval gate | GitLab CI, GitHub Actions, Jenkins |
# .gitlab-ci.yml — GitLab CI pipeline for network automation
stages: - lint - test - deploy lint: stage: lint image: python:3.12 script: - pip install flake8 yamllint black - black --check . - flake8 . --max-line-length=100 - yamllint inventory/ unit_tests: stage: test image: python:3.12 script: - pip install -r requirements.txt pytest - pytest tests/ -v --tb=short deploy_production: stage: deploy image: python:3.12 when: manual # requires human approval in GitLab UI only: - main script: - pip install -r requirements.txt - python automation/deploy.py --env production environment: name: production
13. Best Practices for Network Automation
| Practice | Why It Matters |
| Never store credentials in code | Use environment variables, HashiCorp Vault, or Ansible Vault. Credentials in source code end up in Git history permanently. |
| Test on one device first | Even with lab testing, run on a single production device before all 200. Catch production-specific surprises before they affect everyone. |
| Use dry-run mode | Build a --dry-run flag that shows what would be done without doing it. Essential for pre-deployment review. |
| Back up before changing | Always pull and save running-config before pushing changes. NAPALM’s compare_config() and Netmiko’s backup scripts make this easy. |
| Idempotent scripts | Running the same script twice should produce the same result. Check if a VLAN already exists before creating it. NAPALM and NETCONF naturally handle this; CLI-based scripts need explicit checks. |
| Limit thread count on Nornir | Default is 100 threads. Connecting to 200 devices simultaneously can overwhelm RADIUS/TACACS servers. Set num_workers: 20 in Nornir config for safer parallel execution. |
| Version control everything | Scripts, templates, inventory files, and variable files all belong in Git. Every change is attributable, reversible, and reviewable. Never edit automation scripts directly on a shared server. |
# credentials.py — Safe credential handling with environment variables
import os from getpass import getpass # Option 1: Environment variables (set before running script) # export NET_USERNAME=admin; export NET_PASSWORD=SecurePass123 username = os.environ.get("NET_USERNAME") password = os.environ.get("NET_PASSWORD") # Option 2: Interactive prompt (for one-off scripts) if not password: password = getpass("Network device password: ") # password not echoed to terminal # Option 3: From vault (production — install: pip install hvac) import hvac client = hvac.Client(url="https://vault.example.com", token=os.environ["VAULT_TOKEN"]) secret = client.secrets.kv.read_secret_version(path="network/credentials") username = secret["data"]["data"]["username"] password = secret["data"]["data"]["password"]
14. Frequently Asked Questions
How much Python do I need to know before starting network automation?
Variables, conditionals, loops, functions, and dictionaries — that’s the core. You don’t need object-oriented programming, decorators, or metaclasses to write useful automation scripts. Kirk Byers’ free Python for Network Engineers course (free at PyNet.com) covers exactly the right fundamentals for network engineers without drowning you in computer science concepts that don’t apply to this domain.
Should I use Ansible or Python (Nornir) for network automation?
Ansible is better when your team has more ops/DevOps background and prefers YAML playbooks over Python code. Nornir is better when your team has Python skills and needs maximum flexibility, debuggability, and performance. Many teams use both: Ansible for simple, repeatable workflows (configuration deployment, compliance checks) and Nornir/custom Python for complex logic (multi-step workflows with conditional logic, real-time validation, and complex error handling).
What is the difference between Netmiko, NAPALM, and Nornir?
Think of them as complementary layers. Netmiko handles the SSH connection and device-specific quirks — it’s the transport layer. NAPALM sits on top of Netmiko and other transport methods to provide a vendor-agnostic API with structured returns (getters) and configuration management with diff/rollback. Nornir is a framework that manages running tasks against an inventory of devices in parallel — it uses Netmiko and NAPALM as plugins. Most production automation uses all three together.
How do I handle devices that require jump hosts or bastion servers?
Configure SSH ProxyJump in the device’s SSH config or pass it to Netmiko via the ssh_config_file optional argument. For Nornir, set the proxy host in the connection options per host group. Netmiko also supports sock for SOCKS proxies and has explicit proxy support via the ProxyCommand option that maps directly to standard SSH client tunneling.
Which Python library parses Cisco CLI output into structured data?
Three options. TextFSM + NTC-templates: the most established option; thousands of pre-built templates for Cisco, Arista, Juniper commands; used via use_textfsm=True in Netmiko. TTP (Template Text Parser): more flexible template syntax than TextFSM; easier to write custom templates. Cisco Genie / pyATS: Cisco’s own parsing library; works with Cisco devices specifically and returns deeply structured Python objects; best for Cisco-heavy environments that want the most complete structured output.
Where should I practice Python network automation without real hardware?
Four options in roughly increasing complexity. Cisco DevNet Sandbox: free, always-on IOS-XE, NX-OS, and DNA Center environments; no setup required. Containerlab: deploy virtual network topologies using Docker containers; supports Cisco, Arista, Juniper, Nokia, FRRouting virtual images; runs on any Linux machine. GNS3 or EVE-NG: full network emulation with real vendor images; requires hardware images from Cisco (you need a CCO account). Cisco Modeling Labs (CML): Cisco’s commercial network simulation platform; supported, regularly updated images; annual license.
Quick Reference: Essential Commands & Patterns
| Code / Command | What It Does |
| pip install netmiko napalm nornir | Install core network automation libraries |
| ConnectHandler(**device_dict) | Open Netmiko SSH connection |
| conn.send_command("show version") | Run show command and return output |
| conn.send_config_set([...]) | Push list of config commands |
| device.get_facts() | NAPALM getter: hostname, model, OS, uptime |
| device.compare_config() | Show diff before committing config |
| InitNornir(config_file="config.yaml") | Initialize Nornir from config and inventory |
| nr.filter(groups=["cisco_access"]) | Filter Nornir inventory to specific group |
| nr.run(task=my_function) | Run task in parallel on all devices |
| yaml.safe_load(file) | Parse YAML file to Python dict safely |
Your Network Automation Learning Path
| Week 1–2 | Python basics: variables, loops, functions, dicts, file I/O, error handling. Write a script that reads a YAML file and prints each device’s hostname. |
| Week 3–4 | Netmiko: connect to a device in DevNet sandbox, run show commands, parse output. Write a backup script. Push a simple config change. |
| Week 5–6 | Jinja2 templates: generate a complete switch config from a YAML data file. NAPALM: use getters to pull structured data from two different vendors. |
| Week 7–8 | Nornir: run a compliance check against a 10-device inventory. NETCONF: query interface data from an IOS-XE device using ncclient. |
| Month 3+ | CI/CD pipelines, Git-based change management, NetBox integration, REST APIs, Containerlab for automated testing. Build one complete automation project end-to-end. |