Skip to content

Network Automation with Python

Where Ansible and Terraform hand you guardrails, Python hands you a blank canvas. This article covers the Python network-automation toolbox — which library to reach for by transport, how to push and read configuration with Netmiko, NAPALM, and ncclient, how to turn CLI text into structured data, how to scale across hundreds of devices, and the habits that separate code that works from code that is safe to run in production.

Why Python, and where it fits

Python is a general-purpose language with a deep networking ecosystem, and it quietly underpins the other tools. Ansible modules are written in Python; the Cisco IOS XE Terraform provider ultimately drives RESTCONF, which you can call directly from Python with the requests library. Learning the toolbox pays off twice — once for the scripts you write yourself, and again for understanding how the higher-level tools work underneath.

Reach for Python when a requirement has custom logic that does not fit a declarative tool — conditional workflows, data transformation, gluing several systems together, or anything the model of "declare the end state" cannot express. The cost is ownership: error handling, testing, retries, and security are all yours to build. A declarative tool gives you those for free; Python gives you total flexibility in exchange for writing them.

The toolbox: choose by transport

Pick a library by how you talk to the device (SSH CLI, NETCONF, or REST) and by how custom the logic is.

Library Transport Use it for
netmiko SSH (CLI) Multi-vendor CLI push/read; the most common starting point
scrapli SSH (CLI) A faster, modern CLI library with native asyncio support
napalm SSH / API A multivendor abstraction with one common API and config rollback
ncclient NETCONF YANG-modeled, transactional configuration over SSH (port 830)
requests HTTP RESTCONF and any REST API
Jinja2 — Templating device configuration from data
pyATS / Genie — Cisco's parsing, testing, and validation framework

The first three cover the overwhelming majority of CLI and multivendor work; the rest slot in as the task demands.

Netmiko: automating the CLI

Netmiko's ConnectHandler is a factory that opens an SSH session and returns the right class for your platform based on device_type (for IOS XE use cisco_xe, or cisco_ios). send_config_set enters configuration mode, sends a list of commands, and exits.

# netmiko_vlan.py — push VLANs and configure an access port
import os
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException

device = {
    "device_type": "cisco_xe",
    "host": "192.0.2.11",
    "username": "automation",
    "password": os.environ["NET_PASSWORD"],  # read the secret from the environment
}

vlan_cfg = [
    "vlan 10", "name SALES",
    "vlan 20", "name VOICE",
    "interface GigabitEthernet3",
    "switchport mode access",
    "switchport access vlan 10",
]

try:
    with ConnectHandler(**device) as conn:
        output = conn.send_config_set(vlan_cfg)
        conn.save_config()          # write mem
        print(output)
except (NetmikoTimeoutException, NetmikoAuthenticationException) as err:
    print(f"{device['host']}: {err}")

The with block guarantees the session closes even if an error is raised, and catching Netmiko's timeout and authentication exceptions means an unreachable device or a bad login fails gracefully instead of crashing the whole run.

Turning CLI text into data

Raw show output is just text — fragile to parse with string slicing. Passing use_textfsm=True to send_command runs the output through a TextFSM template (Netmiko bundles the NTC templates) and returns a list of dictionaries you can program against. This is the key to inventory and asset management.

# netmiko_facts.py — structured output for asset management
with ConnectHandler(**device) as conn:
    facts = conn.send_command("show version", use_textfsm=True)
    inv = conn.send_command("show inventory", use_textfsm=True)

# facts is a list of dicts, e.g. [{"version": "17.9.1", "serial": ["FXS..."], ...}]
print(facts[0]["version"])
print(inv[0]["name"])

Prefer structured data like this — or NAPALM's dictionaries below — over parsing strings by hand. It is the difference between a script that survives a firmware upgrade and one that breaks the next time the output format shifts by a space.

NAPALM: one API across vendors

NAPALM (Network Automation and Programmability Abstraction Layer with Multivendor support) gives every supported platform the same method names, so one script runs unchanged against IOS XE, EOS, or Junos. It also exposes a candidate-configuration workflow: stage a change, review the diff, then commit or discard.

# napalm_demo.py — stage, review the diff, then commit
from napalm import get_network_driver

driver = get_network_driver("ios")
dev = driver(hostname="192.0.2.11", username="automation", password=PASSWORD)
dev.open()

print(dev.get_facts())                     # vendor-neutral facts dict
dev.load_merge_candidate(filename="vlan10.cfg")
print(dev.compare_config())                # human-readable diff before you commit
dev.commit_config()                        # or dev.discard_config() to back out
dev.close()

The compare_config() step is what makes NAPALM safe: you see exactly what will change before it lands, and rollback() can revert a committed change on platforms that support it.

ncclient: transactional configuration over NETCONF

When a task needs structured, transactional configuration rather than screen-scraping the CLI, ncclient is the standard NETCONF client. It opens a NETCONF session, lets you get_config and edit_config against YANG models, and — on platforms that support it — use a candidate datastore with an explicit commit, so a batch of changes either fully applies or not at all.

# ncclient_read.py — read interface config as structured XML
from ncclient import manager

with manager.connect(host="192.0.2.11", port=830, username="automation",
                     password=PASSWORD, hostkey_verify=False,
                     device_params={"name": "iosxe"}) as m:
    filt = "<interfaces xmlns='urn:ietf-params:xml:ns:yang:ietf-interfaces'/>"
    reply = m.get_config(source="running", filter=("subtree", filt))
    print(reply)   # XML you can parse into data

The mental model mirrors RESTCONF — the same YANG models, a different transport — but ncclient adds the NETCONF extras (candidate config, locking, commit/discard) that a plain REST call cannot give you. Choosing between requests + RESTCONF and ncclient + NETCONF is the familiar trade-off between quick, RESTful access and careful, transactional change, now expressed in code.

Concurrency: automating hundreds of devices

A loop that logs into devices one at a time is fine for ten switches and painfully slow for five hundred, because almost all of the time is spent waiting on the network, not on the CPU. Network automation is I/O-bound, so a thread pool is usually enough: concurrent.futures.ThreadPoolExecutor runs many SSH or API sessions in parallel and collects the results, cutting a job from minutes to seconds.

# fan_out.py — read one command from many devices in parallel
from concurrent.futures import ThreadPoolExecutor

def grab(host):
    with ConnectHandler(device_type="cisco_xe", host=host,
                        username="automation", password=PASSWORD) as conn:
        return host, conn.send_command("show version", use_textfsm=True)

with ThreadPoolExecutor(max_workers=20) as pool:
    for host, output in pool.map(grab, inventory):
        save(host, output)

Concurrency multiplies your blast radius: twenty parallel workers pushing configuration means a bad change hits twenty devices at once. Cap max_workers sensibly, roll changes out in batches, and validate on a single canary device first — speed is only safe once the change itself has been proven. For genuinely large fleets or when you want a maintained framework rather than a hand-rolled pool, Nornir provides pure-Python inventory and parallel execution built for exactly this.

Write robust code, not just working code

The examples above hint at the habits that matter in production. Make them non-negotiable:

  • Never hard-code credentials. Passwords baked into a .py file end up in version control and logs. Read them from environment variables or a secrets vault.
  • Wrap device calls in try/except. Catch the library's specific exceptions (Netmiko's NetmikoTimeoutException and NetmikoAuthenticationException, for instance) so one unreachable device fails gracefully instead of aborting the run.
  • Prefer structured data over string parsing. use_textfsm=True, NAPALM's dictionaries, and NETCONF's XML all give you data you can rely on across firmware versions.
  • Validate inputs and diff before you commit. A candidate-config workflow (compare_config → commit_config) shows you the change before it lands.

Key takeaways

  • Python is the most flexible option and the foundation of the others — choose it when the logic is too custom for a declarative tool, and accept that error handling, testing, and security become yours to own.
  • Pick the library by transport: netmiko/scrapli for SSH CLI, napalm for a multivendor common API with rollback, ncclient for transactional NETCONF, and requests for RESTCONF/REST.
  • Turn text into data with use_textfsm=True or NAPALM dictionaries — essential for asset management and validation, and far more durable than parsing strings.
  • Scale with concurrency using ThreadPoolExecutor because the work is I/O-bound, but cap workers, batch changes, and canary first — parallelism multiplies your blast radius.
  • Write robust code: keep secrets out of source, wrap calls in try/except, and diff before you commit.

Sources: Netmiko documentation, NAPALM documentation, ncclient documentation, Python concurrent.futures, NTC Templates (TextFSM).