Getting started with pyATS and Genie
Network Validation and Testing for Modern Networks

Introduction
Most network automation projects focus on configuration deployment. Engineers use tools such as Python, Ansible, and NetBox to push changes across hundreds of devices.
However, one critical question remains:
How do you verify that the network is operating correctly after a change?
This is where Cisco pyATS and Genie become valuable.
pyATS (Python Automated Test Systems) is Cisco's open-source network testing and automation framework. At its core, it gives you a structured, programmatic way to connect to network devices, run commands, and validate results instead of manually SSH'ing in and reading the output.
Genie sits on top of pyATS and adds the parts that make it genuinely useful day-to-day :
Parsers — convert raw CLI text into structured Python dictionaries
Models/Ops — vendor-agnostic representations of network features (BGP, OSPF, interfaces, etc.), built by running several show commands and assembling them into one object
Diff utilities — compare two snapshots of state and show exactly what changed
A test framework (
aetest) — for writing pass/fail validation scripts
Why Traditional Validation Is Difficult ?
After a maintenance window, engineers typically perform manual checks such as:
Verify interfaces are up
Check routing adjacencies
Confirm BGP sessions
Verify MPLS LSPs
Check CPU and memory utilization
This approach has several challenges:
Time-consuming
Error-prone
Difficult to scale
No historical comparison
Inconsistent validation procedures
As networks grow, manual validation becomes increasingly unreliable.
What Problem Does pyATS Solve?
If you've ever SSH'd into a router, run a show command, looked at the output, copy-pasted it into a spreadsheet and then repeated that for 20 routers .pyATS and Genie automate exactly that. The result comes back as structured Python data (dictionaries and lists) instead of plain text .
Topology
Step 1 - Install pyATS
Use a virtual environment so this doesn't interfere with other Python projects.
python3 -m venv pyats-env
source pyats-env/bin/activate
pip install pyats[full]
Check if it worked:
pyats version check
You should see version numbers for pyATS and pyATS Library (Genie).
sudo dnf install python3-devel gcc (Rocky/RHEL) or sudo apt install python3-dev gcc (Ubuntu/Debian).Step 2 — Create a Testbed File
A testbed is a YAML file describing your device basically an inventory file. This is the only "config" pyATS needs.
Create testbed.yaml:
testbed:
name: my_first_lab
devices:
cisco_xrv9000:
os: iosxr
type: router
connections:
defaults:
class: unicon.Unicon
cli:
protocol: ssh
ip: 172.20.20.15
port: 22
credentials:
default:
username: <username>
password: <password>
Step 3 — Connect to a Device
Your first script just connect and run a command, the "old fashioned" way:
# 01_connect.py
from genie.testbed import load
tb = load("testbed.yaml")
router1 = tb.devices["cisco_xrv9000"]
router1.connect()
output = router1.execute("show version")
print(output)
router1.disconnect()
Execute it :
python 01_connect.py
At this point, output is just a big string same as what you'd see in a terminal. Nothing magic yet. That changes in the next step.
Step 4 — Your First Parse (The Magic Step)
This is the single most useful thing Genie does. Instead of execute(), use parse():
# 02_parse.py
from genie.testbed import load
tb = load("testbed.yaml")
router1 = tb.devices["cisco_xrv9000"]
router1.connect()
# The key difference: parse() instead of execute()
parsed = router1.parse("show ip interface brief")
print(parsed)
router1.disconnect()
Expected Output:
2026-06-19 13:23:34,058: %UNICON-INFO: +++ cisco_xrv9000 with via 'cli': executing command 'show ip interface brief' +++
show ip interface brief
Fri Jun 19 01:24:13.557 UTC
Interface IP-Address Status Protocol Vrf-Name
Loopback0 10.10.10.3 Up Up default
Loopback100 172.165.11.1 Up Up multi-vendor
MgmtEth0/RP0/CPU0/0 10.0.0.15 Up Up clab-mgmt
GigabitEthernet0/0/0/0 192.168.1.3 Up Up default
GigabitEthernet0/0/0/1 unassigned Shutdown Down default
GigabitEthernet0/0/0/2 10.57.254.3 Up Up multi-vendor
# Magic of parsing is instead of receiving a text you get structured data - python dictionary
RP/0/RP0/CPU0:cisco_xrv9000#
{'interface': {'Loopback0': {'ip_address': '10.10.10.3', 'interface_status': 'Up', 'protocol_status': 'Up', 'vrf_name': 'default'}, 'Loopback100': {'ip_address': '172.165.11.1', 'interface_status': 'Up', 'protocol_status': 'Up', 'vrf_name': 'multi-vendor'}, 'MgmtEth0/RP0/CPU0/0': {'ip_address': '10.0.0.15', 'interface_status': 'Up', 'protocol_status': 'Up', 'vrf_name': 'clab-mgmt'}, 'GigabitEthernet0/0/0/0': {'ip_address': '192.168.1.3', 'interface_status': 'Up', 'protocol_status': 'Up', 'vrf_name': 'default'}, 'GigabitEthernet0/0/0/1': {'ip_address': 'unassigned', 'interface_status': 'Shutdown', 'protocol_status': 'Down', 'vrf_name': 'default'}, 'GigabitEthernet0/0/0/2': {'ip_address': '10.57.254.3', 'interface_status': 'Up', 'protocol_status': 'Up', 'vrf_name':
Now you can use it like any Python dict:
for intf, details in parsed['interface'].items():
if details['interface_status'] != 'Up':
print(f"⚠️ {intf} is DOWN")
else:
print(f"✅ {intf} is UP — {details['ip_address']}")
Expected Output:
2026-06-19 13:59:52,422: %UNICON-INFO: +++ cisco_xrv9000 with via 'cli': executing command 'show ip interface brief' +++
show ip interface brief
Fri Jun 19 02:00:31.950 UTC
Interface IP-Address Status Protocol Vrf-Name
Loopback0 10.10.10.3 Up Up default
Loopback100 172.165.11.1 Up Up multi-vendor
MgmtEth0/RP0/CPU0/0 10.0.0.15 Up Up clab-mgmt
GigabitEthernet0/0/0/0 192.168.1.3 Up Up default
GigabitEthernet0/0/0/1 unassigned Shutdown Down default
GigabitEthernet0/0/0/2 10.57.254.3 Up Up multi-vendor
RP/0/RP0/CPU0:cisco_xrv9000#
✅ Loopback0 is UP — 10.10.10.3
✅ Loopback100 is UP — 172.165.11.1
✅ MgmtEth0/RP0/CPU0/0 is UP — 10.0.0.15
✅ GigabitEthernet0/0/0/0 is UP — 192.168.1.3
⚠️ GigabitEthernet0/0/0/1 is DOWN
✅ GigabitEthernet0/0/0/2 is UP — 10.57.254.3
That's it. You just turned CLI output into something you can filter, alert on, or feed into another system with no regex.
Step 5 — Don't Know If a Parser Exists? Check First
Not every show command has a parser . Two ways to check before you write code:
Option A — From the command line:
genie parse "show ip interface brief" \
--testbed-file testbed.yaml \
--devices cisco_xrv9000
Expected Output:
0%| | 0/1 [00:00<?, ?it/s]{
"interface": {
"GigabitEthernet0/0/0/0": {
"interface_status": "Up",
"ip_address": "192.168.1.3",
"protocol_status": "Up",
"vrf_name": "default"
},
"GigabitEthernet0/0/0/1": {
"interface_status": "Shutdown",
"ip_address": "unassigned",
"protocol_status": "Down",
"vrf_name": "default"
},
"GigabitEthernet0/0/0/2": {
"interface_status": "Up",
"ip_address": "10.57.254.3",
"protocol_status": "Up",
"vrf_name": "multi-vendor"
},
"Loopback0": {
"interface_status": "Up",
"ip_address": "10.10.10.3",
"protocol_status": "Up",
"vrf_name": "default"
},
"Loopback100": {
"interface_status": "Up",
"ip_address": "172.165.11.1",
"protocol_status": "Up",
"vrf_name": "multi-vendor"
},
"MgmtEth0/RP0/CPU0/0": {
"interface_status": "Up",
"ip_address": "10.0.0.15",
"protocol_status": "Up",
"vrf_name": "clab-mgmt"
}
}
}
100%|██████████████████████████████████████████████████████████████████████████| 1/1 [00:00<00:00, 1.25it/s]
Option A — Browse online :
Visit the Genie Feature Browser (pubhub.devnetcloud.com) and search for your platform + command.
Step 6 — learn(): Snapshot an Entire Feature
parse() handles one command. learn() runs several commands and builds a complete model of a feature like OSPF, BGP, or interfaces all in one line.
from genie.testbed import load
tb = load("testbed.yaml")
router1 = tb.devices["cisco_xrv9000"]
router1.connect()
# One line — runs multiple show commands behind the scenes
interfaces = router1.learn("interface")
for name, data in interfaces.info.items():
print(name, "->", data.get("oper_status"))
router1.disconnect()
Expected output :
2026-06-19 14:09:33,278: %UNICON-INFO: +++ cisco_xrv9000 with via 'cli': executing command 'show vrf all detail' +++
show vrf all detail
Fri Jun 19 02:10:12.809 UTC
VRF clab-mgmt; RD not set; VPN ID not set
VRF mode: Regular
Description Containerlab management VRF (DO NOT DELETE)
Interfaces:
MgmtEth0/RP0/CPU0/0
Address family IPV4 Unicast
No import VPN route-target communities
No export VPN route-target communities
No import route policy
No export route policy
Address family IPV6 Unicast
No import VPN route-target communities
No export VPN route-target communities
No import route policy
No export route policy
2026-06-19 14:09:33,656: %UNICON-INFO: +++ cisco_xrv9000 with via 'cli': executing command 'show interface detail' +++
show interface detail
Fri Jun 19 02:10:13.162 UTC
Loopback0 is up, line protocol is up
Interface state transitions: 1
Hardware is Loopback interface(s)
Internet address is 10.10.10.3/32
MTU 1500 bytes, BW 0 Kbit
reliability Unknown, txload Unknown, rxload Unknown
Encapsulation Loopback, loopback not set,
Last link flapped 9w4d
Last input Unknown, output Unknown
Last clearing of "show interface" counters Unknown
Input/output data rate is disabled.
Loopback100 is up, line protocol is up
Interface state transitions: 1
Hardware is Loopback interface(s)
Description: VRF-TEST-LOOPBACK
Internet address is 172.165.11.1/24
MTU 1500 bytes, BW 0 Kbit
reliability Unknown, txload Unknown, rxload Unknown
Encapsulation Loopback, loopback not set,
Last link flapped 9w4d
Last input Unknown, output Unknown
Last clearing of "show interface" counters Unknown
Input/output data rate is disabled.
2026-06-19 14:09:34,238: %UNICON-INFO: +++ cisco_xrv9000 with via 'cli': executing command 'show ethernet tags' +++
show ethernet tags
Fri Jun 19 02:10:13.743 UTC
St: AD - Administratively Down, Dn - Down, Up - Up
Ly: L2 - Switched layer 2 service, L3 = Terminated layer 3 service,
Xtra C - Match on Cos, E - Match on Ethertype, M - Match on source MAC
-,+: Ingress rewrite operation; number of tags to pop and push respectively
2026-06-19 14:09:34,607: %UNICON-INFO: +++ cisco_xrv9000 with via 'cli': executing command 'show interfaces accounting' +++
show interfaces accounting
Fri Jun 19 02:10:14.110 UTC
No accounting statistics available for Loopback100
No accounting statistics available for Null0
GigabitEthernet0/0/0/0
Protocol Pkts In Chars In Pkts Out Chars Out
IPV4_UNICAST 3517881 270803858 27 2220
IPV4_MULTICAST 0 0 1264246 88497220
MPLS 51762 3974532 3032839 1444721933
ARP 407 24420 407 17094
learn () function runs multiple commandsStep 7 — Before/After Diffs (Change Window Pattern)
This is the pattern that makes pyATS genuinely useful day-to-day: snapshot, change, snapshot again, diff.
There are two ways to do this a no-code CLI option (great for quick checks and change windows), and a Python option (better when this logic needs to live inside a larger script).
Option A — No code: genie learn + genie diff
Collect a baseline before your change:
genie learn bgp \
--testbed-file testbed.yaml \
--output baseline
Expected Output:
Learning '['bgp']' on devices '['cisco_xrv9000']'
100%|████████████████████████████████████| 1/1 [00:10<00:00, 10.03s/it]
+=============================================================+
| Genie Learn Summary for device cisco_xrv9000 |
+=============================================================+
| Connected to cisco_xrv9000 |
| - Log: baseline/connection_cisco_xrv9000.txt |
|-------------------------------------------------------------|
| Learnt feature 'bgp' |
| - Ops structure: baseline/bgp_iosxr_cisco_xrv9000_ops.txt |
| - Device Console: baseline/bgp_iosxr_cisco_xrv9000_console.txt |
|=============================================================|
Collect the current state:
genie learn bgp \
--testbed-file testbed.yaml \
--output output
Expected Output:
Learning '['bgp']' on devices '['cisco_xrv9000']'
100%|███████████████████████████████████████| 1/1 [00:10<00:00, 10.53s/it]
+=============================================================+
| Genie Learn Summary for device cisco_xrv9000 |
+=============================================================+
| Connected to cisco_xrv9000 |
| - Log: current/connection_cisco_xrv9000.txt |
|-------------------------------------------------------------|
| Learnt feature 'bgp' |
| - Ops structure: current/bgp_iosxr_cisco_xrv9000_ops.txt |
| - Device Console: current/bgp_iosxr_cisco_xrv9000_console.txt |
|=============================================================|
Now diff the two snapshot folders:
genie diff baseline current
1it [00:00, 441.13it/s]
+=============================================================+
| Genie Diff Summary between directories baseline/ and current/ |
+=============================================================+
| File: bgp_iosxr_cisco_xrv9000_ops.txt |
| - Diff can be found at ./diff_bgp_iosxr_cisco_xrv9000_ops.txt |
|-------------------------------------------------------------|
Expected Output:
cat ./diff_bgp_iosxr_cisco_xrv9000_ops.txt
--- baseline/bgp_iosxr_cisco_xrv9000_ops.txt
+++ current/bgp_iosxr_cisco_xrv9000_ops.txt
info:
instance:
default:
vrf:
default:
neighbor:
10.10.10.1:
- session_state: established
+ session_state: idle
- bgp_negotiated_capabilities:
- four_octets_asn: advertised received
- route_refresh: advertised received
- vpnv4_unicast: advertised received
routes_per_peer:
instance:
default:
vrf:
default:
neighbor:
10.10.10.1:
address_family:
- vpnv4 unicast RD 65000:10:
- routes:
- 10.57.255.0/24:
- index:
- 1:
- next_hop: 10.10.10.1
- origin_codes: i
- status_codes: *>i
- 172.165.10.0/24:
- index:
- 1:
- next_hop: 10.10.10.1
- origin_codes: i
- status_codes: *>i
- vpnv4 unicast RD 65000:30:
- advertised:
- 10.57.254.0/24:
- index:
- 1:
- froms: Local
- next_hop: 10.10.10.3
- origin_code: ?
- 172.165.11.0/24:
- index:
- 1:
- froms: Local
- next_hop: 10.10.10.3
- origin_code: ?
table:
instance:
default:
vrf:
default:
address_family:
- vpnv4 unicast RD 65000:10:
- prefixes:
- 10.57.255.0/24:
- index:
- 1:
- locprf: 100
- next_hop: 10.10.10.1
- origin_codes: i
- status_codes: *>i
- weight: 0
- 172.165.10.0/24:
- index:
- 1:
- locprf: 100
- next_hop: 10.10.10.1
- origin_codes: i
- status_codes: *>i
- weight: 0
- route_distinguisher: 65000:10
Step 8 — A Simple Pass/Fail Test
pyATS has a built-in test framework called aetest. Here's the minimum useful structure: connect, check something, report pass/fail.
from pyats import aetest
from genie.testbed import load
class CommonSetup(aetest.CommonSetup):
@aetest.subsection
def connect(self, testbed):
for device in testbed.devices.values():
device.connect()
class CheckBGP(aetest.Testcase):
@aetest.test
def all_neighbors_established(self, testbed):
router1 = testbed.devices["cisco_xrv9000"]
bgp = router1.parse("show bgp summary")
for instance_name, instance_data in bgp["instance"].items():
for vrf_name, vrf_data in instance_data.get("vrf", {}).items():
for ip, neighbor_data in vrf_data.get("neighbor", {}).items():
# state_pfxrcd is nested under address_family on XR
af_data = neighbor_data.get("address_family", {})
established = False
for af_name, af_details in af_data.items():
pfxrcd = af_details.get("state_pfxrcd")
up_down = af_details.get("up_down")
# A numeric state_pfxrcd means session is Established
if pfxrcd is not None and str(pfxrcd).isdigit():
established = True
self.passed(
f"[{instance_name}/{vrf_name}] {ip} ({af_name}) "
f"is Established — up {up_down}, pfx received: {pfxrcd}"
)
if not established:
self.failed(
f"[{instance_name}/{vrf_name}] {ip} is DOWN"
)
class CommonCleanup(aetest.CommonCleanup):
@aetest.subsection
def disconnect(self, testbed):
for device in testbed.devices.values():
device.disconnect()
def main():
tb = load("testbed.yaml")
aetest.main(testbed=tb)
main()
Expected Output:
RP/0/RP0/CPU0:cisco_xrv9000#
2026-06-22T14:10:47: %AETEST-INFO: Passed reason: [all/default] 10.10.10.1 (ipv4 unicast) is Established — up 2d22h, pfx received: 0
2026-06-22T14:10:47: %AETEST-INFO: The result of section all_neighbors_established is => PASSED
2026-06-22T14:10:47: %AETEST-INFO: The result of testcase CheckBGP is => PASSED
2026-06-22T14:10:47: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:47: %AETEST-INFO: | Starting common cleanup |
2026-06-22T14:10:47: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:47: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:47: %AETEST-INFO: | Starting subsection disconnect |
2026-06-22T14:10:47: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:58: %AETEST-INFO: The result of subsection disconnect is => PASSED
2026-06-22T14:10:58: %AETEST-INFO: The result of common cleanup is => PASSED
2026-06-22T14:10:58: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:58: %AETEST-INFO: | Detailed Results |
2026-06-22T14:10:58: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:58: %AETEST-INFO: SECTIONS/TESTCASES RESULT
2026-06-22T14:10:58: %AETEST-INFO: ----------------------------
2026-06-22T14:10:58: %AETEST-INFO: .
2026-06-22T14:10:58: %AETEST-INFO: |--common_setup PASSED
2026-06-22T14:10:58: %AETEST-INFO: |-- connect PASSED
2026-06-22T14:10:58: %AETEST-INFO: |-- CheckBGP PASSED
2026-06-22T14:10:58: %AETEST-INFO: |-- all_neighbors_established PASSED
2026-06-22T14:10:58: %AETEST-INFO: -- common_cleanup PASSED
2026-06-22T14:10:58: %AETEST-INFO -- disconnect PASSED
2026-06-22T14:10:58: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:58: %AETEST-INFO: | Summary |
2026-06-22T14:10:58: %AETEST-INFO: +--------------------------+
2026-06-22T14:10:58: %AETEST-INFO: Number of ABORTED 0
2026-06-22T14:10:58: %AETEST-INFO: Number of BLOCKED 0
2026-06-22T14:10:58: %AETEST-INFO: Number of ERRORED 0
2026-06-22T14:10:58: %AETEST-INFO: Number of FAILED 0
2026-06-22T14:10:58: %AETEST-INFO: Number of PASSED 3
2026-06-22T14:10:58: %AETEST-INFO: Number of PASSX 0
2026-06-22T14:10:58: %AETEST-INFO: Number of SKIPPED 0
2026-06-22T14:10:58: %AETEST-INFO: Total Number 3
2026-06-22T14:10:58: %AETEST-INFO: Success Rate 100.0%
2026-06-22T14:10:58: %AETEST-INFO: ----------------------------
Quick Reference Cheat Sheet
| What you want to do | Code |
|---|---|
| Load your inventory | tb = load("testbed.yaml") |
| Connect to a device | device.connect() |
| Run a raw command | device.execute("show ...") |
| Get structured data | device.parse("show ...") |
| Snapshot a whole feature | device.learn("bgp") |
| Compare two snapshots | Diff(before, after) diff.findDiff() |
| Check available parsers | genie parsers show --os |
Further Reading
pyATS Documentation — developer.cisco.com/docs/pyats
Genie Feature Browser — pubhub.devnetcloud.com/media/genie-feature-browser
pyATS GitHub — github.com/CiscoTestAutomation/pyats
Cisco DevNet pyATS Learning Labs — developer.cisco.com/learning



