Python for Network Engineers — Part 7: YAML, JSON, and Validating Your Inventory with Pydantic
The inventory in Part 5 was hardcoded Python — a list of dicts in inventory.py. That works for a three-device lab, but in any real environment, you don’t want device details mixed into your code. Credentials change, devices get added and removed, and the people maintaining the inventory may not be comfortable editing Python. Data belongs in files; logic belongs in code.
Network automation uses two formats almost universally: YAML for human-maintained files (inventories, config variables, playbook inputs) and JSON for machine-generated data (API responses, collected device state, tool output). Understanding both, their quirks, and how to validate the data you read is what separates scripts that work in demos from automation that’s safe to run on a production fleet.
JSON
JSON (JavaScript Object Notation) is the lingua franca of APIs. Every vendor REST API — FortiManager, Cisco DNAC, Arista CloudVision, Juniper Mist — returns JSON. When a requests call comes back with device state, that state is JSON. When you store collected data for later analysis, JSON is the natural format.
Python’s json module is part of the standard library — no install needed.
Reading JSON
json.loads() parses a JSON string into Python objects:
import json
raw = '{"hostname": "core-sw-01", "ip": "192.168.1.1", "vlans": [10, 20, 30]}'
data = json.loads(raw)
print(data["hostname"]) # core-sw-01
print(data["vlans"]) # [10, 20, 30]
JSON types map directly to Python types:
| JSON | Python |
|---|---|
object {} | dict |
array [] | list |
string "" | str |
| number | int or float |
true / false | True / False |
null | None |
json.load() (no ‘s’) reads from a file object directly:
with open("devices.json", encoding="utf-8") as f:
data = json.load(f)
Writing JSON
json.dumps() serialises Python objects to a JSON string:
device = {
"hostname": "core-sw-01",
"ip": "192.168.1.1",
"reachable": True,
"vlans": [10, 20, 30],
}
print(json.dumps(device))
# {"hostname": "core-sw-01", "ip": "192.168.1.1", "reachable": true, "vlans": [10, 20, 30]}
indent makes the output human-readable:
print(json.dumps(device, indent=2))
{
"hostname": "core-sw-01",
"ip": "192.168.1.1",
"reachable": true,
"vlans": [10, 20, 30]
}
json.dump() writes to a file:
with open("output.json", "w", encoding="utf-8") as f:
json.dump(device, f, indent=2)
JSON Gotchas
No trailing commas. {"key": "value",} is valid Python but invalid JSON. This catches people out when they write JSON by hand or copy-paste from Python dicts.
No comments. JSON has no comment syntax. If you need commented configuration files, use YAML.
All keys must be strings. Python allows dict keys of any type, but JSON only supports string keys. A dict with integer keys ({10: "vlan10", 20: "vlan20"}) can’t be serialised directly to JSON.
datetime objects aren’t serialisable by default. If you collect timestamps alongside device data, convert them to ISO strings first:
from datetime import datetime, timezone
data = {
"collected_at": datetime.now(timezone.utc).isoformat(),
"hostname": "core-sw-01",
}
json.dumps(data) # works — isoformat() gives a string
YAML
YAML (YAML Ain’t Markup Language) is designed to be written and read by humans. It’s what you reach for when a person needs to maintain the file — inventory, config variables, Ansible playbooks, Nornir inventory. The syntax is clean and readable, but it has enough gotchas that you need to know them before you trust data from it.
Install PyYAML:
uv pip install pyyaml
Basic Structure
YAML represents the same data types as JSON but with a cleaner syntax:
# A YAML mapping (equivalent to a Python dict)
hostname: core-sw-01
ip: 192.168.1.1
reachable: true
port: 22
# A YAML sequence (equivalent to a Python list)
vlans:
- 10
- 20
- 30
# Nested mapping
interfaces:
GigabitEthernet0/0/1:
ip: 10.0.0.1
status: up
GigabitEthernet0/0/2:
ip: 192.168.1.1
status: down
Indentation is significant — use spaces, never tabs. Two spaces per level is the convention.
Reading YAML
Always use yaml.safe_load(), never yaml.load():
import yaml
with open("inventory.yaml", encoding="utf-8") as f:
data = yaml.safe_load(f)
Why safe_load? yaml.load() can execute arbitrary Python code via YAML tags like !!python/object/apply:os.system. If an inventory file is ever tampered with — or sourced from an untrusted location — yaml.load() is a code execution vulnerability. yaml.safe_load() only deserialises standard YAML types. There is no situation in network automation where you need yaml.load().
YAML Gotchas
The Norway Problem. In YAML 1.1 (what PyYAML uses by default), certain bare strings are interpreted as booleans:
| YAML value | Python value |
|---|---|
yes, Yes, YES | True |
no, No, NO | False |
true, True, TRUE | True |
false, False, FALSE | False |
on, On, ON | True |
off, Off, OFF | False |
The Norway problem: NO as a country code parses as False. In networking, no appears in interface descriptions, shutdown commands, and config values. If you write:
interfaces:
- name: GigabitEthernet0/0/1
shutdown: no
shutdown will be False in Python, not the string "no". Quote ambiguous values:
interfaces:
- name: GigabitEthernet0/0/1
shutdown: "no"
country: "NO"
Octal integers. In YAML 1.1, a number with a leading zero is interpreted as octal: 0700 becomes 448, not 700. This doesn’t come up often in networking, but be aware of it if you have values like VLAN IDs or port numbers with leading zeros.
Strings that look like floats. 1.1 in YAML is a float. 192.168.1.1 is a string (it has too many dots to be a number). Most IP addresses are fine, but 1.0 as a software version would parse as a float, losing the trailing zero.
Multiline strings. YAML has two multiline syntaxes. | (literal block) preserves newlines — useful for storing config snippets:
config_snippet: |
interface GigabitEthernet0/0/1
description WAN link
ip address 10.0.0.1 255.255.255.252
> (folded block) collapses newlines into spaces — useful for long descriptions:
description: >
This is a very long description that
wraps across lines in the YAML file
but becomes a single line in Python.
Writing YAML
import yaml
data = {
"hostname": "core-sw-01",
"vlans": [10, 20, 30],
"interfaces": {
"GigabitEthernet0/0/1": {"status": "up"}
}
}
with open("output.yaml", "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True)
default_flow_style=False forces block style (indented, readable) rather than inline {key: value} style.
Preserving Comments with ruamel.yaml
yaml.dump() discards all comments. If a human-maintained inventory file has comments and you need to programmatically update a value while keeping them, use ruamel.yaml:
uv pip install ruamel.yaml
from ruamel.yaml import YAML
ryaml = YAML()
ryaml.preserve_quotes = True
with open("inventory.yaml") as f:
data = ryaml.load(f)
# Modify a value
data["devices"][0]["host"] = "192.168.100.20"
with open("inventory.yaml", "w") as f:
ryaml.dump(data, f)
# Comments and formatting are preserved
If your automation writes back to YAML files that people also maintain by hand — adding devices, updating descriptions, annotating entries — use ruamel.yaml. For read-only workflows, PyYAML is fine.
TOML
TOML (Tom’s Obvious, Minimal Language) is worth a brief mention because it’s increasingly common in the Python ecosystem — pyproject.toml, tool configurations, and some network automation tools use it.
Python 3.11+ includes tomllib in the standard library for reading TOML. For writing, install tomli-w:
uv pip install tomli-w
import tomllib # stdlib, Python 3.11+
with open("config.toml", "rb") as f: # note: binary mode for tomllib
config = tomllib.load(f)
TOML is stricter than YAML — it doesn’t have YAML’s boolean string problem — and it’s better suited for tool configuration than for data files. You won’t reach for it often in network automation, but recognising it helps when you encounter it.
Pydantic: Validating What You Read
Reading a YAML inventory with yaml.safe_load() gives you a dict. Python trusts that dict completely — if the port field is "twenty-two" instead of 22, you won’t find out until Netmiko throws an exception mid-run, possibly after it’s already connected to ten devices.
Pydantic validates the structure and types of your data before your automation logic touches it. Define what the data should look like, and Pydantic raises a clear error at load time if reality doesn’t match.
uv pip install pydantic
Defining a Model
from pydantic import BaseModel, field_validator
from typing import Optional, Literal
VALID_PLATFORMS = {
"arista_eos", "cisco_ios", "cisco_xe", "cisco_nxos",
"cisco_xr", "cisco_asa", "fortinet", "juniper_junos",
"vyos", "linux",
}
class Device(BaseModel):
name: str
host: str
device_type: str
username: str
password: str
port: int = 22
role: Optional[str] = None
@field_validator("port")
@classmethod
def port_must_be_valid(cls, v):
if not (1 <= v <= 65535):
raise ValueError(f"port {v} is out of range (1–65535)")
return v
@field_validator("device_type")
@classmethod
def device_type_must_be_known(cls, v):
if v not in VALID_PLATFORMS:
raise ValueError(
f"unknown device_type {v!r}. "
f"Valid options: {sorted(VALID_PLATFORMS)}"
)
return v
Parsing and Validation
from pydantic import ValidationError
raw = {
"name": "spine-01",
"host": "192.168.100.10",
"device_type": "arista_eos",
"username": "admin",
"password": "netauto",
}
device = Device(**raw)
print(device.host) # 192.168.100.10
print(device.port) # 22 (default applied)
What happens with bad data:
bad = {
"name": "spine-01",
"host": "192.168.100.10",
"device_type": "cisco", # invalid — should be cisco_ios or cisco_xe
"username": "admin",
"password": "netauto",
"port": 99999, # out of range
}
try:
device = Device(**bad)
except ValidationError as e:
print(e)
Output:
2 validation errors for Device
device_type
Value error, unknown device_type 'cisco'. Valid options: ['arista_eos', 'cisco_asa', ...]
port
Value error, port 99999 is out of range (1–65535)
Pydantic catches all validation errors in one pass and reports them together — you don’t fix one and discover the next. That’s intentional, and useful when validating a large inventory.
Converting Back to a Dict
Pydantic models can be serialised back to dicts or JSON easily — important when you need to pass them to functions like ConnectHandler that expect plain dicts:
device_dict = device.model_dump()
# {"name": "spine-01", "host": "192.168.100.10", ...}
# ConnectHandler doesn't accept 'name' or 'role' — exclude them
conn_params = device.model_dump(exclude={"name", "role"})
Capstone: A YAML Inventory with Pydantic Validation
Here’s the payoff — replace Part 5’s hardcoded Python inventory with a validated YAML file.
Create inventory.yaml:
---
# netauto-lab device inventory
# Containerlab topology: topology.yaml
defaults:
username: admin
password: netauto
device_type: arista_eos
port: 22
devices:
- name: spine-01
host: 192.168.100.10
role: spine
- name: leaf-01
host: 192.168.100.11
role: leaf
- name: leaf-02
host: 192.168.100.12
role: leaf
Create inventory_loader.py:
"""Load and validate a YAML inventory file."""
import yaml
from pathlib import Path
from pydantic import BaseModel, field_validator, ValidationError
from typing import Optional
VALID_PLATFORMS = {
"arista_eos", "cisco_ios", "cisco_xe", "cisco_nxos",
"cisco_xr", "cisco_asa", "fortinet", "juniper_junos",
"paloalto_panos", "vyos", "linux",
}
class DeviceDefaults(BaseModel):
username: str
password: str
device_type: str
port: int = 22
class DeviceEntry(BaseModel):
"""A single device entry — may rely on defaults for some fields."""
name: str
host: str
role: Optional[str] = None
device_type: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None
port: Optional[int] = None
@field_validator("port")
@classmethod
def valid_port(cls, v):
if v is not None and not (1 <= v <= 65535):
raise ValueError(f"port {v} out of range")
return v
class InventoryFile(BaseModel):
defaults: DeviceDefaults
devices: list[DeviceEntry]
class Device(BaseModel):
"""A fully resolved device — defaults merged in, ready for ConnectHandler."""
name: str
host: str
device_type: str
username: str
password: str
port: int = 22
role: Optional[str] = None
@field_validator("device_type")
@classmethod
def known_platform(cls, v):
if v not in VALID_PLATFORMS:
raise ValueError(
f"unknown device_type {v!r}. "
f"Valid: {sorted(VALID_PLATFORMS)}"
)
return v
def connection_params(self) -> dict:
"""Return a dict suitable for passing to ConnectHandler."""
return self.model_dump(exclude={"name", "role"})
def load_inventory(path: str | Path) -> list[Device]:
"""Load and validate a YAML inventory file.
Returns a list of fully-resolved Device objects.
Raises ValidationError with all errors if the file is invalid.
"""
with open(path, encoding="utf-8") as f:
raw = yaml.safe_load(f)
inventory = InventoryFile(**raw)
defaults = inventory.defaults
devices = []
for entry in inventory.devices:
# Merge: per-device values override defaults
resolved = Device(
name=entry.name,
host=entry.host,
role=entry.role,
device_type=entry.device_type or defaults.device_type,
username=entry.username or defaults.username,
password=entry.password or defaults.password,
port=entry.port if entry.port is not None else defaults.port,
)
devices.append(resolved)
return devices
if __name__ == "__main__":
devices = load_inventory("inventory.yaml")
for d in devices:
print(f"{d.name:<12} {d.host:<16} {d.device_type} (role: {d.role})")
Run it:
python3 inventory_loader.py
spine-01 192.168.100.10 arista_eos (role: spine)
leaf-01 192.168.100.11 arista_eos (role: leaf)
leaf-02 192.168.100.12 arista_eos (role: leaf)
Now update the collection script from Part 5/6 to use load_inventory() and save results as JSON:
# collect.py — updated to use YAML inventory and save JSON output
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from netmiko import ConnectHandler
from netmiko.exceptions import NetmikoTimeoutException, NetmikoAuthenticationException
from pydantic import ValidationError
from inventory_loader import load_inventory
from parsers import parse_version
OUTPUT_FILE = "collected.json"
def main():
try:
devices = load_inventory("inventory.yaml")
except (FileNotFoundError, ValidationError) as e:
print(f"ERROR loading inventory: {e}")
sys.exit(1)
print(f"\nLoaded {len(devices)} device(s) from inventory.yaml\n")
results = []
failures = []
for device in devices:
print(f" {device.name} ({device.host})... ", end="", flush=True)
try:
with ConnectHandler(**device.connection_params()) as conn:
raw = conn.send_command("show version")
version_data = parse_version(raw, device.device_type)
results.append({
"name": device.name,
"host": device.host,
"role": device.role,
"collected_at": datetime.now(timezone.utc).isoformat(),
"version": version_data,
})
print("OK")
except NetmikoTimeoutException:
failures.append(device.name)
print("FAILED — timeout")
except NetmikoAuthenticationException:
failures.append(device.name)
print("FAILED — auth")
# Save results as JSON
output = {
"run_at": datetime.now(timezone.utc).isoformat(),
"total": len(devices),
"collected": len(results),
"failed": failures,
"devices": results,
}
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2)
print(f"\nResults saved to {OUTPUT_FILE}")
if failures:
sys.exit(1)
if __name__ == "__main__":
main()
After running, collected.json holds a timestamped record of what was collected — readable, diffable in Git, and importable by any other tool in your stack. Adding devices now means editing inventory.yaml, not touching any Python code.
Choosing the Right Format
| Situation | Format |
|---|---|
| Human-maintained inventory, config variables | YAML |
| API request and response bodies | JSON |
| Storing collected device state | JSON |
| Tool configuration (linters, formatters, build tools) | TOML |
| Config that people read but scripts also update | YAML + ruamel.yaml |
| Anything that needs inline validation | Either + Pydantic |
When in doubt: YAML for humans, JSON for machines.
What’s Coming in Part 8
Part 8 covers Jinja2 — the templating engine that turns structured data (like the YAML inventory we just built) into network configuration. Device configs, ACL rule sets, BGP neighbour templates — any configuration that follows a pattern and varies only by input data is a Jinja2 template waiting to be written.
This post uses the same Containerlab topology as Parts 5 and 6. No new lab infrastructure required.