Skip to main content

Command Palette

Search for a command to run...

Getting started with pyATS and Genie

Network Validation and Testing for Modern Networks

Updated
13 min read
Getting started with pyATS and Genie

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 .

💡
Analogy: If SSH + CLI is like reading a paper map, Genie parsing is like using GPS coordinates. Same information, but a computer can use it directly.

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).

💡
Common issue: if installation fails with a build error, install Python dev headers first — 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
💡
Note : the above above output was truncated. The learn () function runs multiple commands

Step 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                  |
|=============================================================|
💡
Simulated an issue on PE1 by dropping the BGP session

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

A

i want toy join this discussion