NETCONF with ncclient (Python)
Traditional network management — SSHing into a device, typing CLI commands, and screen-scraping the output — does not scale. When a change needs to be applied to 200 routers, or when you need to extract a specific value from the configuration of every switch in the network, text-based CLI automation is fragile, vendor-specific, and error-prone. NETCONF (Network Configuration Protocol, RFC 6241) is the industry answer: a structured, transaction-safe, XML-based protocol that lets management software read and write device configuration through well-defined data models rather than parsing human-readable text.
NETCONF runs over SSH on TCP port 830 and uses YANG (Yet Another Next Generation, RFC 6020/7950) data models to describe the structure of configuration and operational data. Every piece of configuration has a defined path in a YANG model — an interface's IP address, a static route, an OSPF area — and NETCONF operations use XML payloads that follow those paths precisely. The result is configuration management that is structured (no text parsing), transactional (changes either fully apply or roll back), and vendor-neutral (OpenConfig models work across vendors; vendor-native models cover vendor-specific features). For the broader automation landscape, see Network Automation Overview and Python for Networking.
ncclient is the most widely used Python library for
NETCONF. It handles the SSH transport, the NETCONF session
handshake, capability negotiation, and XML serialisation — giving
you a clean Python API to send get,
get-config, edit-config,
commit, and lock operations without
writing raw XML transport code. This lab covers the complete
workflow: enabling NETCONF on IOS-XE, connecting with ncclient,
reading configuration and operational state, and pushing
configuration changes using both Cisco-native and OpenConfig
YANG models.
Before starting this lab, ensure NETCONF's SSH transport prerequisites are in place at SSH Configuration. For comparison with CLI-based Python automation, see Python Netmiko Show Commands. For EEM-based on-device automation that complements NETCONF for event-driven config changes, see EEM — Embedded Event Manager Scripting.
1. NETCONF Architecture — Core Concepts
NETCONF Protocol Stack
┌──────────────────────────────────────────────────────┐ │ Management Application (Python + ncclient) │ ├──────────────────────────────────────────────────────┤ │ NETCONF Protocol Layer (RFC 6241) │ │ Operations: get, get-config, edit-config, commit... │ ├──────────────────────────────────────────────────────┤ │ XML Encoding + YANG Data Models │ │ (Cisco-IOS-XE-native, OpenConfig, IETF) │ ├──────────────────────────────────────────────────────┤ │ SSH Transport (TCP port 830) │ └──────────────────────────────────────────────────────┘ NETCONF Datastores: ┌──────────────┬────────────────────────────────────────────┐ │ running │ The active configuration (always present) │ │ startup │ Config saved to NVRAM (loaded at boot) │ │ candidate │ Staging area — edit here, then commit │ │ │ to running. Requires :candidate capability │ └──────────────┴────────────────────────────────────────────┘ IOS-XE supports all three datastores. Most operations in this lab use the running datastore directly.
NETCONF Operations Reference
| Operation | Description | ncclient Method | Analogous CLI |
|---|---|---|---|
<get-config> |
Retrieve configuration data from a datastore (running, startup, or candidate) | m.get_config() |
show running-config |
<get> |
Retrieve both configuration data AND operational/state data (interface counters, BGP state, ARP table) | m.get() |
show interfaces, show ip route |
<edit-config> |
Modify a datastore — merge, replace, create, delete, or remove configuration elements | m.edit_config() |
configure terminal + config commands |
<commit> |
Apply changes from the candidate datastore to the running datastore. Only needed with candidate datastore workflow | m.commit() |
copy running-config startup-config (conceptually) |
<lock> / <unlock> |
Lock a datastore to prevent other sessions from modifying it during a configuration transaction | m.lock() / m.unlock() |
No direct CLI equivalent |
<validate> |
Validate configuration in the candidate datastore against YANG constraints before committing | m.validate() |
No direct CLI equivalent |
<copy-config> |
Copy one complete datastore to another (e.g., candidate to running, running to startup) | m.copy_config() |
copy running-config startup-config |
<delete-config> |
Delete a datastore (cannot delete the running datastore) | m.delete_config() |
write erase (on startup) |
YANG Data Model Types on IOS-XE
| Model Type | Namespace Prefix | Coverage | Use When |
|---|---|---|---|
| Cisco-IOS-XE-native | xmlns="http://cisco.com/ns/yang/Cisco-IOS-XE-native" |
Full IOS-XE CLI feature set — 1:1 mapping to every CLI command. Most comprehensive coverage | Configuring any IOS-XE-specific feature (VRF, GRE, HSRP, QoS, NAT, EEM). When OpenConfig does not cover the feature needed |
| OpenConfig | xmlns="http://openconfig.net/yang/interfaces" (varies by module) |
Vendor-neutral models for interfaces, BGP, IS-IS, OSPF, VLANs, and more. Same model works across Cisco, Juniper, Arista | Writing multi-vendor automation scripts. When portability across vendors is required |
| IETF | xmlns="urn:ietf:params:xml:ns:yang:ietf-interfaces" |
IETF-standardised models for interfaces, IP, routing, NETCONF itself | Standards-compliant automation. Retrieving interface and IP address data portably |
edit-config Operation Attributes
| Attribute | Value | Behaviour | Analogous CLI |
|---|---|---|---|
nc:operation |
merge (default) |
Add or update the element — existing config is preserved, new values are added or overwritten | interface gi1 → description New-Desc |
replace |
Replace the entire element with the provided content — all child elements not in the payload are deleted | Removing and re-entering the entire interface config | |
create |
Create the element only if it does not already exist — returns an error if it exists | Creating a new VLAN that must not already exist | |
delete |
Delete the element — returns an error if it does not exist | no interface loopback 99 |
|
remove |
Delete the element if it exists — no error if it does not exist (idempotent delete) | no interface loopback 99 (idempotent) |
2. Lab Topology & Environment
Python Workstation NetsTuts_R1 (IOS-XE 17.x)
192.168.1.100 Management: 192.168.1.1
| GigabitEthernet1: 192.168.1.1/24
| SSH / NETCONF GigabitEthernet2: 10.0.0.1/24
└──── TCP 830 ────────────────► GigabitEthernet3: 10.0.1.1/24
Loopback0: 1.1.1.1/32
Python environment:
Python 3.10+
ncclient 0.6.13+
lxml 4.9+ (XML parsing)
xmltodict 0.13+ (XML to Python dict conversion)
IOS-XE Requirements:
NETCONF enabled (netconf-yang)
SSH version 2
Local user with privilege 15
NETCONF port 830 reachable from workstation
3. Step 1 — Enable NETCONF on IOS-XE
NETCONF requires SSH to be configured first. The
netconf-yang command enables the NETCONF agent and
opens port 830. A dedicated NETCONF user with privilege 15 is
required for edit-config write operations.
NetsTuts_R1>en NetsTuts_R1#conf t ! ── Step 1: Configure hostname and domain (required for SSH keys) NetsTuts_R1(config)#hostname NetsTuts_R1 NetsTuts_R1(config)#ip domain-name netstuts.lab ! ── Step 2: Generate RSA keys for SSH ───────────────────── NetsTuts_R1(config)#crypto key generate rsa modulus 2048 % Generating 2048 bit RSA keys, keys will be non-exportable... [OK] (elapsed time was 4 seconds) ! ── Step 3: Enable SSH version 2 ───────────────────────── NetsTuts_R1(config)#ip ssh version 2 NetsTuts_R1(config)#ip ssh time-out 60 NetsTuts_R1(config)#ip ssh authentication-retries 3 ! ── Step 4: Create NETCONF management user ──────────────── NetsTuts_R1(config)#username netconf privilege 15 secret NetC0nf$ecret ! ── Step 5: Enable local AAA authentication ─────────────── NetsTuts_R1(config)#aaa new-model NetsTuts_R1(config)#aaa authentication login default local NetsTuts_R1(config)#aaa authorization exec default local ! ── Step 6: Enable NETCONF-YANG agent ───────────────────── NetsTuts_R1(config)#netconf-yang ! ── Step 7: Confirm NETCONF is listening on port 830 ────── NetsTuts_R1(config)#end NetsTuts_R1#show netconf-yang status netconf-yang: enabled netconf-yang candidate-datastore: disabled netconf-yang side-effect-sync: enabled netconf-yang SSH port: 830 ! ── Step 8: Verify from workstation (optional quick check) ─ ! ── (run from Linux/Mac terminal on workstation) ────────── ! nc -zv 192.168.1.1 830 ! Connection to 192.168.1.1 830 port [tcp] succeeded!
netconf-yang command enables the NETCONF agent
alongside any existing SSH access on port 22 — they are
independent. Port 830 is the IANA-assigned NETCONF port and is
the default used by ncclient. The aaa new-model
and authorization commands are required on IOS-XE for NETCONF
sessions to authenticate successfully — without them, the SSH
connection to port 830 is established but the NETCONF session
hello exchange fails with an authentication error. See
AAA Overview and
SSH Configuration
for full AAA and SSH prerequisites.
Optional: Enable Candidate Datastore
! ── Enable candidate datastore for staged config workflow ─ NetsTuts_R1(config)#netconf-yang feature candidate-datastore NetsTuts_R1#show netconf-yang status netconf-yang: enabled netconf-yang candidate-datastore: enabled ← now available netconf-yang SSH port: 830
edit-config
operations, validate the result, and then commit all changes
atomically to the running datastore in a single
commit operation. If any validation fails, the
running configuration is unchanged. This is the preferred
approach for production configuration changes. The labs in this
guide use the running datastore directly for simplicity, with
candidate datastore examples shown where relevant.
4. Step 2 — Install ncclient and Connect
Install Python Dependencies
# ── Run on the Python workstation ───────────────────────── # ── Create and activate a virtual environment (recommended) python3 -m venv netconf-lab source netconf-lab/bin/activate # Linux/Mac # netconf-lab\Scripts\activate # Windows # ── Install required packages ───────────────────────────── pip install ncclient lxml xmltodict # ── Verify installation ─────────────────────────────────── python3 -c "import ncclient; print(ncclient.__version__)" 0.6.13
Script 1 — Connect and Inspect Device Capabilities
#!/usr/bin/env python3
"""
netconf_01_connect.py
Connect to IOS-XE via NETCONF and display device capabilities.
Capabilities describe which YANG models and NETCONF features
the device supports.
"""
from ncclient import manager
import xml.dom.minidom
# ── Device connection parameters ──────────────────────────
DEVICE = {
"host": "192.168.1.1",
"port": 830,
"username": "netconf",
"password": "NetC0nf$ecret",
"hostkey_verify": False, # Set True in production with known_hosts
"device_params": {"name": "iosxe"}, # Enables IOS-XE specific handling
"manager_params": {"timeout": 60},
}
def main():
print(f"Connecting to {DEVICE['host']}:{DEVICE['port']} via NETCONF...")
with manager.connect(**DEVICE) as m:
print(f"\nConnected successfully.")
print(f"Session ID : {m.session_id}")
print(f"\n{'='*60}")
print("DEVICE CAPABILITIES (YANG models supported):")
print('='*60)
# ── Print all capabilities advertised by the device ──
for cap in sorted(m.server_capabilities):
print(f" {cap}")
# ── Check for key capabilities ────────────────────────
print(f"\n{'='*60}")
print("KEY CAPABILITY CHECKS:")
print('='*60)
checks = {
":candidate": "Candidate datastore",
":rollback-on-error": "Auto rollback on error",
":validate": "Config validation",
":confirmed-commit": "Confirmed commit (auto-rollback)",
"Cisco-IOS-XE-native": "Cisco IOS-XE native YANG models",
"openconfig-interfaces": "OpenConfig interfaces model",
"ietf-interfaces": "IETF interfaces model",
}
for cap_fragment, label in checks.items():
found = any(cap_fragment in c for c in m.server_capabilities)
status = "✓ Supported" if found else "✗ Not found"
print(f" {label:<40} {status}")
if __name__ == "__main__":
main()
with manager.connect(**DEVICE) as m: pattern
automatically closes the NETCONF session when the block exits,
even if an exception occurs. The
device_params={"name": "iosxe"} argument enables
ncclient's IOS-XE-specific behaviour — it handles IOS-XE's
slightly non-standard NETCONF hello exchange and adds
commit calls after edit-config on the
running datastore automatically. Setting
hostkey_verify=False is acceptable in a lab
environment but should be set to True in production
with the device's SSH host key added to ~/.ssh/known_hosts.
Expected Output
Connecting to 192.168.1.1:830 via NETCONF... Connected successfully. Session ID : 47 ============================================================ DEVICE CAPABILITIES (YANG models supported): ============================================================ urn:ietf:params:netconf:base:1.0 urn:ietf:params:netconf:base:1.1 urn:ietf:params:netconf:capability:candidate:1.0 urn:ietf:params:netconf:capability:rollback-on-error:1.0 urn:ietf:params:netconf:capability:validate:1.1 urn:ietf:params:netconf:capability:confirmed-commit:1.1 http://cisco.com/ns/yang/Cisco-IOS-XE-native?module=... http://openconfig.net/yang/interfaces?module=... urn:ietf:params:xml:ns:yang:ietf-interfaces?module=... ... (50+ more capability URIs) ============================================================ KEY CAPABILITY CHECKS: ============================================================ Candidate datastore ✓ Supported Auto rollback on error ✓ Supported Config validation ✓ Supported Confirmed commit (auto-rollback) ✓ Supported Cisco IOS-XE native YANG models ✓ Supported OpenConfig interfaces model ✓ Supported IETF interfaces model ✓ Supported
5. Step 3 — Retrieve Configuration with get-config
The get-config operation retrieves configuration
data from a datastore. A subtree filter narrows
the response to a specific section of the configuration tree,
avoiding the overhead of retrieving the entire running
configuration for every query.
Script 2 — Retrieve All Interface Configuration
#!/usr/bin/env python3
"""
netconf_02_get_interfaces.py
Retrieve all interface configuration using the Cisco-IOS-XE-native
YANG model with a subtree filter, then parse the XML response.
"""
from ncclient import manager
from lxml import etree
import xmltodict, json
DEVICE = {
"host": "192.168.1.1", "port": 830,
"username": "netconf", "password": "NetC0nf$ecret",
"hostkey_verify": False, "device_params": {"name": "iosxe"},
}
# ── Subtree filter: retrieve only the section ─
# ── from the Cisco-IOS-XE-native model ───────────────────
INTERFACE_FILTER = """
"""
def main():
with manager.connect(**DEVICE) as m:
# ── Send get-config with the subtree filter ────────
response = m.get_config(source="running", filter=INTERFACE_FILTER)
# ── response.xml is the raw XML string ────────────
# ── response.data_ele is the parsed lxml element ──
# ── Pretty-print the raw XML response ─────────────
print("=== RAW XML RESPONSE ===")
pretty_xml = etree.tostring(
response.data_ele,
pretty_print=True
).decode()
print(pretty_xml[:3000]) # Truncate for display
# ── Convert to Python dict for easier processing ──
print("\n=== PARSED AS PYTHON DICT ===")
response_dict = xmltodict.parse(response.xml)
# ── Navigate to the interface list ────────────────
try:
native = response_dict["rpc-reply"]["data"]["native"]
interfaces = native.get("interface", {})
# ── Print GigabitEthernet interfaces ──────────
gi_list = interfaces.get("GigabitEthernet", [])
if isinstance(gi_list, dict):
gi_list = [gi_list] # Single interface — wrap in list
print("\nGigabitEthernet Interfaces:")
print("-" * 50)
for gi in gi_list:
name = gi.get("name", "unknown")
desc = gi.get("description", "(no description)")
ip_cfg = gi.get("ip", {})
addr = ip_cfg.get("address", {})
pri = addr.get("primary", {})
ip = pri.get("address", "not set")
mask = pri.get("mask", "")
shut = gi.get("shutdown")
status = "shutdown" if shut is not None else "no shutdown"
print(f" Gi{name}: {ip}/{mask} [{desc}] {status}")
except KeyError as e:
print(f"Parse error — key not found: {e}")
print("Full dict structure:")
print(json.dumps(response_dict, indent=2)[:2000])
if __name__ == "__main__":
main()
<native xmlns="..."><interface/></native>
tells NETCONF: "return only the interface subtree
within the native YANG model namespace." Without a filter,
get_config(source="running") returns the entire
running configuration as XML — hundreds of kilobytes for a
complex device. The filter dramatically reduces response size
and processing time. The xmltodict.parse() function
converts the XML response into a nested Python dictionary,
making it easy to navigate with standard Python key access. Note
the isinstance(gi_list, dict) check — when a device
has only one interface of a type, xmltodict returns a dict
instead of a list; the check normalises this to always be a list.
Expected Output
=== RAW XML RESPONSE ===
<data xmlns="urn:ietf:params:xml:ns:netconf:base:1.0"
xmlns:nc="urn:ietf:params:xml:ns:netconf:base:1.0">
<native xmlns="http://cisco.com/ns/yang/Cisco-IOS-XE-native">
<interface>
<GigabitEthernet>
<name>1</name>
<description>Management Interface</description>
<ip>
<address>
<primary>
<address>192.168.1.1</address>
<mask>255.255.255.0</mask>
</primary>
</address>
</ip>
<negotiation>
<auto>true</auto>
</negotiation>
</GigabitEthernet>
<GigabitEthernet>
<name>2</name>
<ip>...</ip>
</GigabitEthernet>
...
=== PARSED AS PYTHON DICT ===
GigabitEthernet Interfaces:
--------------------------------------------------
Gi1: 192.168.1.1/255.255.255.0 [Management Interface] no shutdown
Gi2: 10.0.0.1/255.255.255.0 [(no description)] no shutdown
Gi3: 10.0.1.1/255.255.255.0 [(no description)] no shutdown
Script 3 — Retrieve Specific Interface by Name (Precise Filter)
#!/usr/bin/env python3
"""
netconf_03_get_one_interface.py
Use a precise subtree filter to retrieve only GigabitEthernet2.
Adding a value inside a filter element makes it an exact-match key.
"""
from ncclient import manager
import xmltodict
DEVICE = {
"host": "192.168.1.1", "port": 830,
"username": "netconf", "password": "NetC0nf$ecret",
"hostkey_verify": False, "device_params": {"name": "iosxe"},
}
# ── Filter: match only GigabitEthernet with name = "2" ────
# ── The 2 inside is a key ──
# ── selector — it returns ONLY that interface ─────────────
SINGLE_INTF_FILTER = """
2
"""
def main():
with manager.connect(**DEVICE) as m:
response = m.get_config(source="running", filter=SINGLE_INTF_FILTER)
data = xmltodict.parse(response.xml)
try:
gi = (data["rpc-reply"]["data"]["native"]
["interface"]["GigabitEthernet"])
print(f"Interface: GigabitEthernet{gi['name']}")
print(f" Description : {gi.get('description', '(none)')}")
ip_addr = (gi.get("ip", {})
.get("address", {})
.get("primary", {})
.get("address", "not configured"))
ip_mask = (gi.get("ip", {})
.get("address", {})
.get("primary", {})
.get("mask", ""))
print(f" IP Address : {ip_addr} {ip_mask}")
print(f" Shutdown : {'yes' if gi.get('shutdown') is not None else 'no'}")
except (KeyError, TypeError) as e:
print(f"Interface not found or parse error: {e}")
if __name__ == "__main__":
main()
Script 4 — Retrieve Operational State with get (not get-config)
#!/usr/bin/env python3
"""
netconf_04_get_oper.py
Use <get> (not <get-config>) to retrieve OPERATIONAL state data —
interface counters, line protocol status, input/output rates.
Operational data is NOT in the running config datastore.
Uses the IETF interfaces operational YANG model.
"""
from ncclient import manager
from lxml import etree
DEVICE = {
"host": "192.168.1.1", "port": 830,
"username": "netconf", "password": "NetC0nf$ecret",
"hostkey_verify": False, "device_params": {"name": "iosxe"},
}
# ── Filter: operational state of GigabitEthernet interfaces
# ── Uses ietf-interfaces-state (RFC 7223) ─────────────────
OPER_FILTER = """
GigabitEthernet2
"""
def main():
with manager.connect(**DEVICE) as m:
# ── Use get() for operational data ────────────────
# ── (get-config only returns config, not oper state)
response = m.get(filter=("subtree", OPER_FILTER))
print("=== OPERATIONAL STATE (IETF model) ===")
print(etree.tostring(
response.data_ele,
pretty_print=True
).decode())
if __name__ == "__main__":
main()
get() operation retrieves both configuration
data and operational state data — interface
counters, protocol status, BGP session state, ARP tables, and
routing tables. The get-config() operation retrieves
only configuration data. This distinction maps to
the YANG concept of config vs state nodes: config nodes appear in
both get-config and get responses;
state nodes (operational data) only appear in get
responses. Note the filter syntax for get() uses a
tuple: filter=("subtree", FILTER_STRING) while
get_config() uses the filter=FILTER_STRING
keyword directly. See
show interfaces for the
equivalent CLI operational data.
6. Step 4 — Push Configuration with edit-config
The edit-config operation modifies the device
configuration by sending an XML payload that follows the YANG
model structure. The payload specifies the target datastore,
the default operation (merge, replace),
and the configuration tree to apply.
Script 5 — Configure Interface Description and IP Address
#!/usr/bin/env python3
"""
netconf_05_edit_interface.py
Configure GigabitEthernet2 description and IP address using
the Cisco-IOS-XE-native YANG model with edit-config merge.
"""
from ncclient import manager
from ncclient.operations import RPCError
DEVICE = {
"host": "192.168.1.1", "port": 830,
"username": "netconf", "password": "NetC0nf$ecret",
"hostkey_verify": False, "device_params": {"name": "iosxe"},
}
# ── XML payload: configure GigabitEthernet2 ───────────────
# ── operation="merge" adds/updates without removing other config
# ── The namespace must match the YANG model exactly ────────
INTF_CONFIG = """
2
WAN-Link-to-ISP-A
10.0.0.1
255.255.255.0
true
"""
def main():
with manager.connect(**DEVICE) as m:
print(f"Configuring GigabitEthernet2 on {DEVICE['host']}...")
try:
# ── Send edit-config to the running datastore ──
response = m.edit_config(
target="running",
config=INTF_CONFIG
)
# ── Check response status ──────────────────────
if response.ok:
print("✓ edit-config succeeded — GigabitEthernet2 configured")
else:
print(f"✗ edit-config returned errors: {response.errors}")
except RPCError as e:
# ── NETCONF server returned an <rpc-error> ──────
print(f"✗ RPC Error: {e}")
print(f" Error type : {e.type}")
print(f" Error tag : {e.tag}")
print(f" Error severity: {e.severity}")
print(f" Error message : {e.message}")
# ── Verify: read back the config we just pushed ───
print("\nVerifying by reading back GigabitEthernet2...")
verify_filter = """
2
"""
verify_resp = m.get_config(source="running", filter=verify_filter)
import xmltodict
data = xmltodict.parse(verify_resp.xml)
gi = (data["rpc-reply"]["data"]["native"]
["interface"]["GigabitEthernet"])
desc = gi.get("description", "(none)")
addr = (gi.get("ip", {}).get("address", {})
.get("primary", {}).get("address", "not set"))
mask = (gi.get("ip", {}).get("address", {})
.get("primary", {}).get("mask", ""))
print(f" Description : {desc}")
print(f" IP Address : {addr} {mask}")
if __name__ == "__main__":
main()
edit_config() calls in a
try/except RPCError block — the device returns an
RPC error for invalid values, read-only nodes, or constraint
violations (e.g., configuring a secondary IP before a primary).
The error's tag attribute provides a standardised
error type (bad-element, operation-failed,
data-missing, etc.) and the message
attribute contains the device's human-readable explanation.
Always read back the configuration after
pushing changes to confirm the device accepted and stored the
values as intended — a successful response.ok means
the RPC was accepted, not necessarily that the config matches
what you intended.
Script 6 — Configure a Loopback Interface (Create)
#!/usr/bin/env python3
"""
netconf_06_create_loopback.py
Create Loopback99 with an IP address.
Uses nc:operation="create" — returns an error if it already exists.
Use nc:operation="merge" for idempotent create/update.
"""
from ncclient import manager
from ncclient.operations import RPCError
DEVICE = {
"host": "192.168.1.1", "port": 830,
"username": "netconf", "password": "NetC0nf$ecret",
"hostkey_verify": False, "device_params": {"name": "iosxe"},
}
# ── nc: namespace prefix must be declared for operation attr
LOOPBACK_CREATE = """
99
NETCONF-Lab-Loopback
99.99.99.99
255.255.255.255
"""
def main():
with manager.connect(**DEVICE) as m:
print("Creating Loopback99...")
try:
resp = m.edit_config(target="running", config=LOOPBACK_CREATE)
if resp.ok:
print("✓ Loopback99 created: 99.99.99.99/32")
else:
print(f"✗ Errors: {resp.errors}")
except RPCError as e:
if e.tag == "data-exists":
print("ℹ Loopback99 already exists — use merge to update")
else:
print(f"✗ RPC Error [{e.tag}]: {e.message}")
if __name__ == "__main__":
main()
Script 7 — Delete a Configuration Element
#!/usr/bin/env python3
"""
netconf_07_delete_loopback.py
Delete Loopback99 using nc:operation="remove"
(idempotent — no error if the interface does not exist).
"""
from ncclient import manager
from ncclient.operations import RPCError
DEVICE = {
"host": "192.168.1.1", "port": 830,
"username": "netconf", "password": "NetC0nf$ecret",
"hostkey_verify": False, "device_params": {"name": "iosxe"},
}
# ── operation="remove" is idempotent: no error if not present
# ── operation="delete" raises data-missing error if not present
LOOPBACK_DELETE = """
99
"""
def main():
with manager.connect(**DEVICE) as m:
print("Removing Loopback99...")
try:
resp = m.edit_config(target="running", config=LOOPBACK_DELETE)
if resp.ok:
print("✓ Loopback99 removed (or did not exist)")
except RPCError as e:
print(f"✗ RPC Error [{e.tag}]: {e.message}")
if __name__ == "__main__":
main()
7. Step 5 — Practical YANG Model Payloads
This section provides ready-to-use XML payloads for common
configuration tasks, covering both Cisco-IOS-XE-native and
OpenConfig models. Each can be used directly as the
config argument to m.edit_config().
Configure a Static Route (Cisco-IOS-XE-native)
STATIC_ROUTE = """""" # ── Usage ────────────────────────────────────────────────── # with manager.connect(**DEVICE) as m: # m.edit_config(target="running", config=STATIC_ROUTE) 0.0.0.0 0.0.0.0 10.0.0.254
Configure NTP Server (Cisco-IOS-XE-native)
NTP_CONFIG = """""" 216.239.35.0
Configure a VLAN (Cisco-IOS-XE-native)
VLAN_CONFIG = """""" 100 NETCONF-VLAN
Configure Interface Using OpenConfig Model
#!/usr/bin/env python3
"""
netconf_08_openconfig_interface.py
Configure interface description using the OpenConfig interfaces
YANG model instead of the Cisco-native model.
OpenConfig payloads work across Cisco, Juniper, and Arista.
"""
from ncclient import manager
from ncclient.operations import RPCError
DEVICE = {
"host": "192.168.1.1", "port": 830,
"username": "netconf", "password": "NetC0nf$ecret",
"hostkey_verify": False, "device_params": {"name": "iosxe"},
}
# ── OpenConfig namespace: http://openconfig.net/yang/interfaces ─
# ── Interface name format for IOS-XE in OpenConfig: ────────────
# ── "GigabitEthernet2" (full name, not just "2") ───────────────
OC_INTF_CONFIG = """
GigabitEthernet2
GigabitEthernet2
WAN-to-ISP-OpenConfig
true
"""
def main():
with manager.connect(**DEVICE) as m:
# ── Verify device supports OpenConfig interfaces ───
oc_supported = any(
"openconfig-interfaces" in c
for c in m.server_capabilities
)
if not oc_supported:
print("✗ OpenConfig interfaces model not supported on this device")
return
print("Configuring GigabitEthernet2 via OpenConfig model...")
try:
resp = m.edit_config(target="running", config=OC_INTF_CONFIG)
if resp.ok:
print("✓ Interface configured via OpenConfig")
except RPCError as e:
print(f"✗ RPC Error [{e.tag}]: {e.message}")
if __name__ == "__main__":
main()
xmlns) and the data model
structure beneath it. OpenConfig uses a consistent
<config> container inside each model element
(e.g., <interface><config><description>)
while Cisco-native maps more directly to CLI hierarchy
(<interface><GigabitEthernet><description>).
OpenConfig also uses the full interface name string
(GigabitEthernet2) rather than the IOS-XE-native
numeric-only format (2). For basic interface
configuration reference, see
Basic Interface
Configuration.
Candidate Datastore Workflow — Staged Configuration
#!/usr/bin/env python3
"""
netconf_09_candidate_workflow.py
Best-practice production workflow using the candidate datastore:
1. Lock candidate datastore
2. Push multiple edit-config operations to candidate
3. Validate the candidate
4. Commit atomically to running
5. Unlock
If any step fails, discard changes — running config unchanged.
"""
from ncclient import manager
from ncclient.operations import RPCError
DEVICE = {
"host": "192.168.1.1", "port": 830,
"username": "netconf", "password": "NetC0nf$ecret",
"hostkey_verify": False, "device_params": {"name": "iosxe"},
}
CHANGE_1 = """
2
WAN-Primary-Configured-via-NETCONF
"""
CHANGE_2 = """
0
Router-ID-Loopback
"""
def main():
with manager.connect(**DEVICE) as m:
# ── Verify candidate datastore is supported ────────
if not any(":candidate" in c for c in m.server_capabilities):
print("✗ Candidate datastore not supported — use running directly")
return
print("Starting candidate datastore workflow...")
try:
# ── Step 1: Lock the candidate datastore ──────
print(" 1. Locking candidate datastore...")
m.lock(target="candidate")
# ── Step 2: Push changes to candidate ─────────
print(" 2. Pushing change 1 to candidate...")
resp1 = m.edit_config(target="candidate", config=CHANGE_1)
if not resp1.ok:
raise Exception(f"Change 1 failed: {resp1.errors}")
print(" 3. Pushing change 2 to candidate...")
resp2 = m.edit_config(target="candidate", config=CHANGE_2)
if not resp2.ok:
raise Exception(f"Change 2 failed: {resp2.errors}")
# ── Step 3: Validate before committing ────────
print(" 4. Validating candidate configuration...")
m.validate(source="candidate")
print(" ✓ Validation passed")
# ── Step 4: Commit to running ──────────────────
print(" 5. Committing to running datastore...")
m.commit()
print(" ✓ Commit successful — changes are now live")
except (RPCError, Exception) as e:
# ── Rollback: discard candidate changes ────────
print(f"\n✗ Error: {e}")
print(" Rolling back — discarding candidate changes...")
try:
m.discard_changes()
print(" ✓ Candidate discarded — running config unchanged")
except RPCError as discard_err:
print(f" ✗ Discard failed: {discard_err}")
finally:
# ── Always unlock regardless of outcome ────────
try:
m.unlock(target="candidate")
print(" Candidate datastore unlocked")
except RPCError:
pass # May already be unlocked if lock failed
if __name__ == "__main__":
main()
lock()
prevents other NETCONF sessions or CLI operators from modifying
the candidate concurrently. The validate() call
checks the full candidate configuration against all YANG
constraints before any change reaches the running configuration.
The discard_changes() in the exception handler
guarantees that a failed workflow leaves the running
configuration exactly as it was — no partial changes. The
finally block ensures the lock is always released,
even if the commit itself fails, preventing the candidate
datastore from being permanently locked and blocking future
NETCONF sessions.
8. Step 6 — Verification and Troubleshooting NETCONF Sessions
Verify NETCONF on IOS-XE
! ── Check NETCONF agent status ─────────────────────────── NetsTuts_R1#show netconf-yang status netconf-yang: enabled netconf-yang candidate-datastore: enabled netconf-yang side-effect-sync: enabled netconf-yang SSH port: 830 ! ── List active NETCONF sessions ───────────────────────── NetsTuts_R1#show netconf-yang sessions R: global-lock S: SID Username Transport Source Elapsed time ---------------------------------------------------------------------- S: 47 netconf netconf-ssh 192.168.1.100:52413 00:00:15 ! ── Show session details including YANG capabilities ────── NetsTuts_R1#show netconf-yang sessions detail session-id: 47 transport: netconf-ssh username: netconf source-host: 192.168.1.100 login-time: 2024-10-16T14:22:01+00:00 in-rpcs: 12 in-bad-rpcs: 0 out-rpc-errors:0 out-notifications: 0 ! ── View NETCONF statistics ─────────────────────────────── NetsTuts_R1#show netconf-yang statistics netconf-yang server statistics: in-sessions : 8 in-bad-hellos : 0 in-rpcs : 47 in-bad-rpcs : 2 out-rpc-errors : 2 out-notifications : 0 dropped-sessions : 0 ! ── Check NETCONF debug logs ───────────────────────────── NetsTuts_R1#show logging | include NETCONF\|netconf\|yang ! ── Enable NETCONF debug (use only in lab — very verbose) ─ ! NetsTuts_R1#debug netconf all ! NetsTuts_R1#undebug all
NETCONF Troubleshooting Reference
| Problem | Symptom | Cause | Fix |
|---|---|---|---|
| Connection refused on port 830 | ncclient.transport.errors.SSHError: [Errno 111] Connection refused |
netconf-yang not enabled on the device, or port 830 blocked by an ACL between workstation and device |
Run netconf-yang in global config. Verify with show netconf-yang status. Check ACLs with show ip access-lists — ensure TCP port 830 is permitted inbound on the management interface |
| Authentication failure | ncclient.transport.errors.AuthenticationError |
Wrong username/password, or aaa authorization exec default local not configured — NETCONF requires exec authorisation to establish the session |
Verify credentials. Add aaa new-model, aaa authentication login default local, and aaa authorization exec default local to the device config |
| RPC error: bad-element | RPCError: tag=bad-element, message=element is not valid in this context |
The XML payload references a YANG node that does not exist, is misspelled, or uses a wrong namespace. The element name or namespace does not match the YANG model | Verify the exact XML path using the YANG model explorer (YANG Suite, pyang, or netconf-console). Compare against known-working payloads. Check that the xmlns namespace matches the target model exactly |
| RPC error: operation-failed | RPCError: tag=operation-failed, message=... |
The configuration value is semantically invalid — IP address format wrong, value out of range, YANG constraint violated (e.g., secondary IP without primary), or attempting a write on a read-only state node | Check the error message for the specific constraint violated. Verify IP addresses, masks, and values match the YANG model's constraints. State nodes (operational data) cannot be written — verify you are using the config branch of the model |
| Empty response from get-config | response.xml contains only the RPC reply envelope with an empty data element |
The subtree filter does not match anything in the running configuration — either the namespace is wrong, the element name is incorrect, or the key value (e.g., interface name) does not match | Try the same filter without a key selector first (retrieve all of that type). Verify the namespace with show netconf-yang capabilities on the device. Check interface naming — IOS-XE native uses <name>2</name> while OpenConfig uses <name>GigabitEthernet2</name> |
| Candidate datastore lock failure | RPCError: tag=lock-denied, message=access to requested lock is denied |
Another NETCONF session (or CLI operator) already holds the candidate lock, or a previous script crashed without calling unlock() |
Check active sessions with show netconf-yang sessions. Kill the offending session if appropriate. In the script, always place unlock() in a finally block to guarantee release even on exceptions |
| Changes lost after reload | Configuration changes applied via NETCONF are present in running-config but disappear after a device reload | edit-config to the running datastore modifies the running configuration but does not save to NVRAM (startup-config) |
After edit-config to running, also call m.copy_config(source="running", target="startup") or send the CLI equivalent via a separate SSH session. Alternatively use the candidate datastore workflow, which on IOS-XE can be configured to auto-save |
9. Reusable NETCONF Helper Class
The following class wraps the most common NETCONF operations into a reusable, production-ready interface for building larger automation scripts and pipelines.
#!/usr/bin/env python3
"""
netconf_helper.py
Reusable NETCONF helper class for IOS-XE automation.
Wraps ncclient with error handling, logging, and convenience methods.
"""
from ncclient import manager
from ncclient.operations import RPCError
from lxml import etree
import xmltodict
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
log = logging.getLogger(__name__)
class NetconfDevice:
"""
Context-manager wrapper around ncclient.manager for IOS-XE.
Usage:
with NetconfDevice("192.168.1.1", "netconf", "pass") as dev:
data = dev.get_config_subtree(FILTER)
dev.edit_running(PAYLOAD)
"""
def __init__(self, host, username, password, port=830,
hostkey_verify=False):
self.host = host
self.port = port
self.username = username
self.password = password
self.hostkey_verify = hostkey_verify
self._manager = None
# ── Context manager entry ──────────────────────────────
def __enter__(self):
log.info(f"Connecting to {self.host}:{self.port} via NETCONF")
self._manager = manager.connect(
host=self.host, port=self.port,
username=self.username, password=self.password,
hostkey_verify=self.hostkey_verify,
device_params={"name": "iosxe"},
manager_params={"timeout": 60},
)
log.info(f"Connected — session ID {self._manager.session_id}")
return self
# ── Context manager exit ───────────────────────────────
def __exit__(self, exc_type, exc_val, exc_tb):
if self._manager and self._manager.connected:
self._manager.close_session()
log.info("NETCONF session closed")
return False # Do not suppress exceptions
# ── get-config with subtree filter ────────────────────
def get_config_subtree(self, filter_xml: str,
source: str = "running") -> dict:
"""
Retrieve config data using a subtree filter.
Returns parsed Python dict.
"""
log.debug(f"get-config from {source}")
resp = self._manager.get_config(
source=source, filter=filter_xml
)
return xmltodict.parse(resp.xml)
# ── get with subtree filter (config + oper state) ──────
def get_oper(self, filter_xml: str) -> dict:
"""Retrieve operational state data using get()."""
resp = self._manager.get(filter=("subtree", filter_xml))
return xmltodict.parse(resp.xml)
# ── edit-config to running ────────────────────────────
def edit_running(self, config_xml: str) -> bool:
"""
Push config to running datastore.
Returns True on success, raises RPCError on failure.
"""
log.info("Sending edit-config to running datastore")
try:
resp = self._manager.edit_config(
target="running", config=config_xml
)
if resp.ok:
log.info("edit-config succeeded")
return True
else:
log.error(f"edit-config errors: {resp.errors}")
return False
except RPCError as e:
log.error(f"RPCError [{e.tag}]: {e.message}")
raise
# ── Candidate datastore workflow ───────────────────────
def push_candidate(self, *config_payloads: str,
validate: bool = True) -> bool:
"""
Push one or more config payloads via candidate datastore.
Validates and commits atomically. Discards on any error.
Returns True on successful commit.
"""
m = self._manager
if not any(":candidate" in c for c in m.server_capabilities):
log.warning("Candidate not supported — falling back to running")
for payload in config_payloads:
self.edit_running(payload)
return True
log.info("Starting candidate datastore workflow")
try:
m.lock(target="candidate")
log.debug("Candidate locked")
for i, payload in enumerate(config_payloads, 1):
log.info(f" Applying change {i}/{len(config_payloads)}")
resp = m.edit_config(target="candidate", config=payload)
if not resp.ok:
raise Exception(f"Change {i} failed: {resp.errors}")
if validate:
m.validate(source="candidate")
log.info("Validation passed")
m.commit()
log.info("Committed to running successfully")
return True
except (RPCError, Exception) as e:
log.error(f"Workflow failed: {e} — discarding candidate")
try:
m.discard_changes()
log.info("Candidate discarded")
except RPCError:
pass
raise
finally:
try:
m.unlock(target="candidate")
log.debug("Candidate unlocked")
except RPCError:
pass
# ── Save running to startup ────────────────────────────
def save_config(self) -> bool:
"""Copy running-config to startup-config (NVRAM)."""
log.info("Saving running-config to startup-config")
try:
self._manager.copy_config(
source="running", target="startup"
)
log.info("Config saved to NVRAM")
return True
except RPCError as e:
log.error(f"Save failed [{e.tag}]: {e.message}")
return False
# ── Get server capabilities ────────────────────────────
def capabilities(self) -> list:
return list(self._manager.server_capabilities)
# ── Pretty-print XML ───────────────────────────────────
@staticmethod
def pretty(xml_str: str) -> str:
return etree.tostring(
etree.fromstring(xml_str.encode()),
pretty_print=True
).decode()
# ── Example usage ──────────────────────────────────────────
if __name__ == "__main__":
LOOPBACK_PAYLOAD = """
10
Automation-Test
10.10.10.10
255.255.255.255
"""
INTF_FILTER = """
10
"""
with NetconfDevice("192.168.1.1", "netconf", "NetC0nf$ecret") as dev:
# ── Push config via candidate workflow ─────────────
dev.push_candidate(LOOPBACK_PAYLOAD)
# ── Read back and display ──────────────────────────
data = dev.get_config_subtree(INTF_FILTER)
lo = (data.get("rpc-reply", {}).get("data", {})
.get("native", {}).get("interface", {})
.get("Loopback", {}))
print(f"Loopback{lo.get('name')}: "
f"{lo.get('ip',{}).get('address',{}).get('primary',{}).get('address','?')}")
# ── Save to NVRAM ──────────────────────────────────
dev.save_config()
Key Points & Exam Tips
- NETCONF (RFC 6241) is a structured configuration management protocol that runs over SSH on TCP port 830. It uses XML-encoded payloads and YANG data models to read and write device configuration in a structured, transactional way — replacing fragile CLI text parsing for network automation.
- YANG (RFC 6020/7950) defines the schema (data types, hierarchy, constraints) for configuration and operational data. YANG models tell NETCONF what the XML payload structure must look like. The three main model families on IOS-XE are Cisco-IOS-XE-native (full feature coverage, IOS-specific), OpenConfig (vendor-neutral, portable across Cisco/Juniper/Arista), and IETF (standards-defined, interfaces and routing).
- The four most important NETCONF operations:
get-config(retrieve configuration from a datastore),get(retrieve configuration AND operational state),edit-config(modify a datastore), andcommit(apply candidate to running). Know the difference betweenget-config(config only) andget(config + oper state). - The three datastores are running (active config), startup (NVRAM/boot config), and candidate (staging area). The candidate datastore enables staged, atomic, transactional changes: edit candidate → validate → commit to running → all changes apply or none do.
edit-config target="running"changes take effect immediately but are not saved to NVRAM automatically. - Subtree filters in
get-configandgetnarrow the XML response to only the requested portion of the configuration tree. Adding a value inside a filter element (e.g.,<name>2</name>) makes it a key selector that returns only that specific entry. Without filters,get-config(source="running")returns the entire config tree. - The
nc:operationattribute controls howedit-confighandles existing data:merge(add/update, default),replace(replace entire subtree),create(only if not exists),delete(must exist, errors if absent),remove(delete if exists, idempotent). Useremovefor idempotent deletion in automation scripts. - The XML namespace (
xmlns) in NETCONF payloads must match the YANG model exactly. The most common cause ofbad-elementRPC errors is a wrong namespace, a misspelled element name, or using Cisco-native path structure with an OpenConfig namespace (or vice versa). Always verify the namespace against the device's capability list. - Always use a
try/except RPCErrorblock aroundedit-configoperations and always placeunlock()in afinallyblock when using locks. An uncaught exception that leaves a candidate lock held will block all subsequent NETCONF sessions until the lock is manually cleared. - For production automation: use the candidate datastore workflow (lock → edit → validate → commit → unlock), always verify changes by reading them back with
get-config, and save to startup withcopy_config(source="running", target="startup")after successful commits. Changes torunningare lost on reload if not saved. - On the exam: know NETCONF's port (TCP 830), the three datastores, the key operations (get/get-config/edit-config/commit), the difference between Cisco-native and OpenConfig models, the role of YANG, and the IOS-XE prerequisites (
netconf-yang, AAA, SSH v2). Compare NETCONF against RESTCONF (HTTPS/JSON alternative, RFC 8040) and CLI automation (Netmiko/screen-scraping).