Cisco uses PyATS to execute millions of automated tests every month across their infrastructure and products.
That's not millions per year. Millions per month.
If you're building production network automation—whether deploying configurations, upgrading devices, or making bulk changes—you need validation. PyATS is how enterprise teams prove their automation actually worked.
# BEFORE automation: Capture current statebefore_state=get_vlan_count(switch_ip)print(f"Before: {before_state} VLANs exist")# RUN YOUR AUTOMATION (provision 50 new VLANs)provision_vlans(switch_ip,vlan_list)# AFTER automation: Validate state changedafter_state=get_vlan_count(switch_ip)assertafter_state==before_state+50,"VLAN provisioning failed!"print(f"✅ Validation passed: {after_state} VLANs now exist")
Result: You have proof. Not "we think it worked." Actual proof.
testbed:name:"ProductionNetwork"# The testbed name identifies this set of devices# You can have multiple testbeds (prod, staging, lab)devices:switch-01:# Device name — referenced in Python as testbed.devices['switch-01']# This is just a label you choose (can be hostname, IP, or any identifier)type:switch# Device type (switch, router, etc.) — helps PyATS optimize communicationsos:ios# Operating system — determines which parser to use# Options: ios, iosxe, nxos, junos, etc.connections:# Connection profiles — can have multiple (cli, netconf, etc.)cli:# 'cli' means SSH connection for CLI commandsprotocol:ssh# Protocol to use (ssh, telnet, etc.)ip:"10.1.1.1"# Device IP address or hostnameport:22# SSH port (default 22)credentials:# Define credentials for this connectiondefault:# 'default' is the credential set name (can have multiple)username:admin# Username for device loginpassword:!vault|# Encrypted password stored securely in vault format# This is Ansible Vault encryption (secure)# Never store plaintext passwordsswitch-02:# Second device with same structuretype:switchos:iosconnections:cli:protocol:sship:"10.1.1.2"port:22credentials:default:username:adminpassword:!vault|# Another encrypted password
How PyATS Uses This:
When you call loader.load('testbed.yaml'), PyATS:
Reads the YAML file and parses the structure
Creates a Testbed object containing all devices
Each device becomes a Device object with connection settings
When you call device.connect(), PyATS uses settings from this file to establish SSH session
Credentials are automatically decrypted (if using Ansible Vault)
Key Concept — Why Define Once, Use Everywhere:
Define connectivity once in testbed.yaml (IPs, credentials, protocols)
Reference in multiple test files: testbed.devices['switch-01']
If device IP changes, update testbed.yaml once—all tests automatically use new IP
Credentials encrypted at rest, decrypted on-demand by PyATS
Credential Security:
❌ Bad:password: admin123 in plaintext (visible to anyone with file access)
✅ Good:password: !vault |... (encrypted, can only decrypt with vault password)
Use: ansible-vault encrypt_string 'your_password' --name 'password' to encrypt passwords securely.
frompyats.topologyimportloader# Load testbed — This reads testbed.yaml and creates device objects# The testbed contains all device definitions, credentials, and connectivity detailstestbed=loader.load('testbed.yaml')# Get a specific device from the testbed# testbed.devices is a dictionary of all devices defined in testbed.yaml# 'switch-01' must match a device name in testbed.yamldevice=testbed.devices['switch-01']# Connect to the device# This initiates SSH connection using credentials from testbed.yaml# PyATS handles connection pooling and session management automaticallydevice.connect()# Parse CLI output automatically# device.parse() sends 'show vlan' to the device and processes the output# Instead of getting raw text, PyATS uses Genie parsers to convert output to structured data# This parser is specific to the device OS (defined in testbed.yaml)output=device.parse('show vlan')# The output is a structured dictionary, not raw text:# {# 'vlans': {# '1': {# 'name': 'default',# 'status': 'active',# 'interfaces': {'Ethernet0/1': {...}, ...}# },# '10': {# 'name': 'DATA',# 'status': 'active',# 'interfaces': {'Ethernet0/2': {...}, ...}# }# }# }# Easy to validate using dictionary access (no regex, no parsing strings)assert'10'inoutput['vlans'],"VLAN 10 missing!"# This checks if key '10' exists in vlans dictionary# Much more reliable than searching for "VLAN 10" in text output
Breaking Down Each Line:
loader.load('testbed.yaml') — Reads your testbed file and creates device objects. No need to manually create connections.
testbed.devices['switch-01'] — Access a specific device by name. Must match device definition in testbed.yaml.
device.connect() — Establishes SSH session. Credentials come from testbed.yaml (encrypted).
device.parse('show vlan') — Sends command to device, receives output, runs Cisco's Genie parser on it.
output['vlans'] — Dictionary key access. Parsed data is always in the same structure regardless of device output formatting.
'10' in output['vlans'] — Check if VLAN ID exists. Uses dictionary keys, not string searching.
Why This Matters:
❌ Without parsing: Regex patterns that break when output format changes, fragile text processing
✅ With PyATS: Structured data, consistent dictionary access, parser handles all OS variations
Key Insight: Genie parsers are Python modules that understand Cisco CLI output format. When you call device.parse('show vlan'), PyATS:
Sends show vlan to device
Gets raw text output
Runs the Genie parser for "show vlan" (specific to device OS)
Returns structured dictionary
Different OSes (IOS, IOS-XE, NX-OS) return slightly different structures, but the parser handles all that complexity.
importpytestfrompyats.topologyimportloader@pytest.fixture(scope='session')deftestbed():""" Fixture: Load testbed once per test session scope='session' means this runs once for the entire test suite Subsequent test functions reuse the same testbed object This is efficient—no need to reload testbed.yaml for each test """returnloader.load('testbed.yaml')@pytest.fixturedefdevice(testbed):""" Fixture: Connect to a device and yield it to tests The fixture: 1. Gets the device from testbed 2. Connects via SSH (using credentials from testbed.yaml) 3. 'yield' passes device to the test function 4. After test completes, cleanup code runs (disconnect) Each test function that needs a device parameter receives this """dev=testbed.devices['switch-01']# Connect to device using settings from testbed.yamldev.connect()# 'yield' pauses fixture and passes device to test# Test function runs hereyielddev# This code runs AFTER test completes (cleanup)dev.disconnect()deftest_vlan_exists(device):""" Test function: Check that VLAN exists Receives 'device' from the fixture above This is the PyATS Device object, already connected pytest runs this function and: 1. Calls the device fixture (which connects) 2. Passes device to this function 3. Runs the assertions 4. Fixture cleanup runs (disconnect) """# Parse show vlan output into structured dataoutput=device.parse('show vlan')# Check: Is VLAN 10 in the vlans dictionary?assert'10'inoutput['vlans'],"VLAN 10 not found!"# If assertion passes, test passes# If fails, test fails and shows error messagedeftest_vlan_has_name(device):""" Test function: Check VLAN has correct name """# Parse show vlan again (same pattern as above)output=device.parse('show vlan')# Access nested dictionary: output['vlans']['10'] gives us VLAN 10 data# VLAN 10 data is a dict with keys: 'name', 'status', 'interfaces', etc.vlan_10_data=output['vlans']['10']# Check: Does VLAN 10 have the expected name?assertvlan_10_data['name']=='DATA',"VLAN 10 name mismatch!"# This accesses the 'name' key from the VLAN data dictionarydeftest_vlan_has_interfaces(device):""" Test function: Check VLAN has expected interfaces """output=device.parse('show vlan')# Navigate nested structure: vlans → 10 → interfaces# VLAN 10 interfaces is a dictionary of interface namesvlan_10_interfaces=output['vlans']['10']['interfaces']# vlan_10_interfaces looks like:# {'Ethernet0/1': {...}, 'Ethernet0/2': {...}, ...}# Check: Is Ethernet0/1 in the interfaces?assert'Ethernet0/1'invlan_10_interfaces,"Ethernet0/1 missing from VLAN 10!"# '.keys()' gives us interface names, we check if one exists
defcapture_baseline(device):""" Capture network state BEFORE automation runs This becomes our "truth" for comparison later """# Create dictionary to store baseline measurementsbaseline={# Count how many VLANs exist right now'vlan_count':len(device.parse('show vlan')['vlans']),# Explanation: device.parse() returns dict, ['vlans'] is the vlans section# len() counts how many VLAN IDs (keys in the vlans dictionary)# Store the routing table before changes'routes':device.parse('show ip route')['route'],# Again using dictionary access to extract 'route' section from parsed output# Count how many interfaces are operationally up'interfaces_up':count_up_interfaces(device),# Helper function to count up interfaces (defined below)# Store BGP neighbor information'bgp_neighbors':device.parse('show ip bgp summary')['device']['bgp_id'],# Navigating multiple levels: dict['device'] → dict['bgp_id']}returnbaseline# Return dict for comparison after automation runsdefcount_up_interfaces(device):"""Helper function: Count interfaces that are operationally up"""# Parse show interfaces outputinterfaces=device.parse('show interfaces')# Count how many have oper_status == 'up'# Using list comprehension: [name for name, data in interfaces.items() if condition]up_count=len([nameforname,dataininterfaces.items()ifdata.get('oper_status')=='up'])# .items() gives us (interface_name, interface_data) tuples# .get('oper_status') safely accesses key (returns None if missing)# if condition filters only those that are 'up'# len() counts how many matchreturnup_count
defrun_automation(device):""" Your actual automation code This runs AFTER baseline capture, BEFORE validation """fromnetmikoimportConnectHandler# Connect via Netmiko for configuration capability# We use Netmiko here because it's optimized for config commands# We use PyATS for parsing (read-only), Netmiko for configs (write)conn=ConnectHandler(device_type='cisco_ios',# Device type tells Netmiko how to communicate with this devicehost=device.connections.cli.ip,# Get device IP from PyATS device object# device.connections.cli is the connection object, .ip is the IP addressusername='admin',password='...',# Credentials (in production, get from testbed or vault))# Your automation commandsconn.send_config_set([# List of configuration commands to send'vlan 100','name PROD-VLAN','vlan 101','name PROD-VLAN-2',# Each string is a command. Netmiko sends them sequentially# Netmiko waits for device prompts so next command doesn't send too early])# Always disconnect Netmiko connection when doneconn.disconnect()# Important: Don't reuse Netmiko connections, create fresh ones per automation
Why separate Netmiko from PyATS:
Netmiko: Best for pushing configs (send_command, send_config_set)
PyATS: Best for parsing device state (parse, structured data)
defvalidate_automation(device,baseline):""" Capture network state AFTER automation and compare to baseline Returns validation results showing what passed/failed """# Capture state after automationafter={# Same measurements as baseline'vlan_count':len(device.parse('show vlan')['vlans']),'routes':device.parse('show ip route')['route'],'interfaces_up':count_up_interfaces(device),# Now we can compare these to baseline values}# Assert expectations — if these fail, automation failed# Check 1: Did we create 2 new VLANs?assertafter['vlan_count']==baseline['vlan_count']+2, \
# Expected 2 more VLANs than baselinef"VLAN count mismatch! " \
f"Expected {baseline['vlan_count']+2}, " \
f"got {after['vlan_count']}"# Error message shows actual vs expected# Check 2: Are the new VLANs actually there?vlan_data=device.parse('show vlan')['vlans']# Get VLAN data after automationassert'100'invlan_data,"VLAN 100 not found!"# Check VLAN 100 existsassert'101'invlan_data,"VLAN 101 not found!"# Check VLAN 101 exists# Check 3: Did any interfaces go down unexpectedly?assertafter['interfaces_up']==baseline['interfaces_up'], \
# Should have same number of up interfacesf"Interfaces went down! " \
f"Before: {baseline['interfaces_up']}, " \
f"After: {after['interfaces_up']}"returnafter# Return after-state for reporting or further analysis
Why each assertion matters:
vlan_count mismatch — Catches if fewer VLANs created than expected
VLAN doesn't exist — Catches if VLAN created but already deleted
Interfaces down — Catches unexpected side effects from configuration
deftest_vlan_provisioning_with_validation(testbed):""" Complete before/after validation test Shows the full workflow: baseline → automation → validation """device=testbed.devices['switch-01']device.connect()# Connect to device using PyATS# BEFORE: Capture baselinebaseline=capture_baseline(device)# Capture current network stateprint(f"Baseline: {baseline['vlan_count']} VLANs exist")# Show baseline for debugging# DURING: Run automationrun_automation(device)# Configure new VLANs via Netmiko# AFTER: Validate resultsafter=validate_automation(device,baseline)# Compare states, run assertions# Report resultsprint(f"✅ Validation passed!")print(f" VLANs: {baseline['vlan_count']} → {after['vlan_count']}")print(f" Interfaces up: {baseline['interfaces_up']} (no change)")device.disconnect()# Clean up connection
testbed:name:"LabNetwork"# Testbed name — identifies this set of devicesdevices:csr1000v:# Device name — referenced in code as testbed.devices['csr1000v']type:router# Device type (router, switch, firewall, etc.)os:iosxe# OS determines parser used# Genie has separate parsers for: ios, iosxe, nxos, ios-xr, junos, etc.connections:cli:protocol:ssh# Protocol to use for connection (ssh recommended, telnet legacy)ip:192.168.1.10# Device IP or hostnameport:22# SSH port (default 22)credentials:default:# Credential set name — can have multiple (primary, secondary, etc.)username:admin# Username for device loginpassword:!vault|# Ansible Vault encrypted password (secure, not plaintext)# See Step 3 below for how to create this$ANSIBLE_VAULT;1.2;AES256;default# Your encrypted password here
# Encrypt a password interactivelyansible-vaultencrypt_string
# Enter password when prompted: (type your device password and hit Enter twice)# Output:# !vault |# $ANSIBLE_VAULT;1.2;AES256;default# ...long encrypted string...# Vault password: (enter vault password)# Copy entire "!vault |" block into testbed.yaml
Why encrypt credentials?
❌ Bad:password: admin123 visible to anyone with file access
✅ Good:password: !vault |... encrypted, only decryptable with vault password
frompyats.topologyimportloader# Load testbed — parses YAML and creates device objectstestbed=loader.load('testbed.yaml')# Get device by name (must match testbed.yaml)device=testbed.devices['csr1000v']# Connect to device# PyATS uses credentials from testbed.yaml# Decrypts vault-encrypted password automaticallydevice.connect()# Verify connection successfulprint(f"✅ Connected to {device.name}")# device.name is hostname or device name from testbed# Always disconnect when donedevice.disconnect()
What happens during device.connect():
PyATS reads connection settings from testbed.yaml
Gets device IP, username, password
Automatically decrypts vault password
Initiates SSH session
Logs in to device
Ready for parsing and commands
Real-World Example: Validate a Configuration Change¶
importpytestfrompyats.topologyimportloaderfromnetmikoimportConnectHandler@pytest.fixturedeftestbed():returnloader.load('testbed.yaml')@pytest.fixturedefdevice(testbed):dev=testbed.devices['csr1000v']dev.connect()yielddevdev.disconnect()deftest_interface_configuration(device):""" Scenario: Configure Ethernet0/1 with IP 10.0.1.1/24 Validate: Interface exists, has correct IP, is up """# Connect via Netmiko to configurefromnetmikoimportConnectHandlernet_connect=ConnectHandler(device_type='cisco_ios',host=device.connections.cli.ip,username='admin',password=device.credentials['default'].password,)# Run configuration commandscommands=['interface Ethernet0/1','ip address 10.0.1.1 255.255.255.0','no shutdown',]net_connect.send_config_set(commands)net_connect.disconnect()# NOW VALIDATE WITH PYATS# Parse interface configurationinterfaces=device.parse('show interfaces')eth01=interfaces.get('Ethernet0/1',{})# Validate interface existsasserteth01,"Ethernet0/1 not found!"# Validate interface is upasserteth01.get('enabled')==True,"Interface not enabled!"asserteth01.get('oper_status')=='up',"Interface not up!"# Parse IP addressip_config=device.parse('show ip interface Ethernet0/1')ip_addr=ip_config['Ethernet0/1']['ipv4']['10.0.1.1']['ip']# Validate IP addressassertip_addr=='10.0.1.1',f"IP mismatch! Got {ip_addr}"print("✅ Validation passed: Interface configured correctly")
Production automation without validation is guesswork. PyATS transforms validation from manual checklist to automated proof. Enterprise teams use it millions of times per month for good reason.