Skip to content

Pydantic Data Validation

Pydantic Data Validation for Network Automation

"From Hoping Your Data Is Right to Proving It โ€” Typed Models at the Boundary"

You've modelled your intent in YAML and learned to exchange it as JSON. Both tutorials ended the same way: you loaded a file, got a Python dictionary back, and carried on. Pydantic is the step you're missing between "I loaded the data" and "I trust the data".

A dictionary will hold anything. It will happily hold VLAN 5000, an IP address with a typo, a platfrom key you misspelled at 23:40 during a change window, and an mtu of "1500 " with a trailing space from a copy-paste. None of that fails when you load it. It fails later โ€” usually on the device, usually halfway through a loop, usually after you've already configured eleven switches.

Why Pydantic belongs in your automation:

  • โœ… Fail at the boundary, not on the device โ€” Bad data is rejected before a single SSH session opens
  • โœ… Types you can rely on โ€” device.mgmt_ip is an IP address object, not a string that looks like one
  • โœ… Errors an operator can act on โ€” Pydantic names the file, the host, the field and the reason
  • โœ… Self-documenting intent โ€” The model is the specification of what your data must look like
  • โœ… One tool, both formats โ€” The same model validates a YAML inventory and a JSON API response
  • โœ… Catches typos in keys โ€” The single highest-value check you can add to an inventory

Real-world impact: The difference between a change that fails safely in 0.2 seconds on your laptop and one that fails unsafely in 40 seconds across half a site.


๐ŸŽฏ What You'll Learn

By the end of this tutorial, you'll understand:

  • โœ… Why raw dictionaries are the wrong shape to build automation on
  • โœ… Defining models with BaseModel and reading ValidationError
  • โœ… Network-native field types (IP addresses, networks, constrained integers, literals)
  • โœ… Nested models for devices, interfaces and VLANs
  • โœ… Validating a complete YAML inventory file
  • โœ… Custom rules with @field_validator and @model_validator
  • โœ… Rejecting unknown keys with extra="forbid"
  • โœ… Validating JSON API responses from a controller
  • โœ… Handing a validated model to Jinja2
  • โœ… When to reach for Pydantic and when to reach for JSON Schema

๐Ÿ“‹ Prerequisites

Required Knowledge

  • โœ… Completed YAML Data Modelling โ€” Structuring device intent
  • โœ… Completed JSON Data Handling โ€” APIs and schema validation
  • โœ… Comfortable with Python classes and type hints

Required Software

# Create a virtual environment
python -m venv pydantic_venv
source pydantic_venv/bin/activate
# Windows PowerShell: .\pydantic_venv\Scripts\Activate.ps1
# Windows CMD: pydantic_venv\Scripts\activate.bat

# Install required packages
pip install "pydantic>=2.0" pyyaml requests jinja2

This Tutorial Is Pydantic v2

Pydantic v2 was a substantial rewrite, and a great deal of network automation content online still shows v1 syntax. If you copy an example from elsewhere and it uses @validator, .dict() or a nested class Config:, it is v1 and will not work here. There's a migration table at the end of this tutorial.

Confirm your version before you start:

python -c "import pydantic; print(pydantic.VERSION)"

Required Access

  • No device access required โ€” every example in this tutorial runs offline

๐Ÿ” The Problem: What a Dictionary Costs You

In the YAML tutorial you wrote a validator that looked something like this:

for host_name, host_data in hosts.items():
    if 'hostname' not in host_data:
        print(f"โœ— Host '{host_name}' missing 'hostname' field")
        return False

That's honest code and it catches a real error. But look at what it doesn't catch. Here's an inventory that passes it completely:

# inventory.yaml โ€” every one of these is wrong, none of them are caught
core-sw-01:
  hostname: 10.1.1.300          # not a valid IP โ€” .300 doesn't exist
  platform: ios
  mtu: "1500 "                  # string with trailing whitespace, not an int
  site: MAN1

dist-sw-01:
  hostname: 10.1.1.2
  platfrom: ios                 # typo โ€” the real platform key is missing
  mtu: 9216
  site: MAN1

access-sw-01:
  hostname: 10.1.1.3
  platform: iso                 # transposed โ€” no such platform
  mtu: 1500
  vlan: 5000                    # outside the valid 1โ€“4094 range
  site: MAN1

Four defects. Your check returns True. The script proceeds.

The failures then surface in four different places at four different times: the IP fails when Netmiko tries to connect, the MTU string fails when Jinja2 renders mtu 1500 into the config, the missing platform fails when Netmiko looks up a device driver, and VLAN 5000 fails on the switch โ€” after you've already pushed to the first two devices.

Every one of those is knowable before you connect to anything. That's the entire argument for Pydantic.


๐Ÿงฑ Your First Model

A Pydantic model is a class. The annotations are the specification.

#!/usr/bin/env python3
"""
A minimal device model.
"""

from pydantic import BaseModel, ValidationError


class Device(BaseModel):
    name: str
    hostname: str
    platform: str
    mtu: int


# Valid data โ€” this works
device = Device(
    name="core-sw-01",
    hostname="10.1.1.1",
    platform="ios",
    mtu=1500,
)

print(device)
print(device.name)     # attribute access, not device['name']
print(device.mtu + 1)  # a real int โ€” arithmetic just works

Output:

name='core-sw-01' hostname='10.1.1.1' platform='ios' mtu=1500
core-sw-01
1501

Now feed it something broken:

try:
    Device(name="core-sw-01", hostname="10.1.1.1", platform="ios", mtu="not-a-number")
except ValidationError as e:
    print(e)

Output:

1 validation error for Device
mtu
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='not-a-number', input_type=str]
    For further information visit https://errors.pydantic.dev/2.13/v/int_parsing

Three things to notice, because they're the whole value proposition:

  1. It failed immediately โ€” at construction, not at use.
  2. It named the field โ€” mtu, not "something went wrong".
  3. It showed the offending value โ€” 'not-a-number'.

Coercion Is On by Default

Device(..., mtu="1500") succeeds and gives you mtu=1500 as an integer. Pydantic coerces where the conversion is unambiguous and lossless. This is usually what you want with YAML, where a quoted number is a common accident. If you need to reject the string form outright, see strict mode later on.


๐ŸŒ Network-Native Field Types

str and int are a start, but hostname: str still accepts 10.1.1.300. Pydantic ships types that understand the things networks are made of.

#!/usr/bin/env python3
"""
A device model using network-aware types.
"""

from typing import Annotated, Literal

from pydantic import BaseModel, Field, IPvAnyAddress, IPvAnyNetwork

# A VLAN ID is an integer between 1 and 4094 โ€” say so once, reuse everywhere
VlanId = Annotated[int, Field(ge=1, le=4094)]


class Device(BaseModel):
    name: Annotated[str, Field(min_length=1, max_length=63)]
    mgmt_ip: IPvAnyAddress
    mgmt_net: IPvAnyNetwork
    platform: Literal["ios", "iosxe", "nxos", "iosxr"]
    mtu: Annotated[int, Field(ge=1500, le=9216)]
    native_vlan: VlanId


device = Device(
    name="core-sw-01",
    mgmt_ip="10.1.1.1",
    mgmt_net="10.1.1.0/24",
    platform="iosxe",
    mtu=9216,
    native_vlan=999,
)

print(type(device.mgmt_ip))          # <class 'ipaddress.IPv4Address'>
print(device.mgmt_ip in device.mgmt_net)   # True โ€” real network maths

What each type buys you:

Type Rejects Gives you
IPvAnyAddress 10.1.1.300, not-an-ip An ipaddress.IPv4Address/IPv6Address object
IPvAnyNetwork 10.1.1.0/33, host bits set An IPv4Network you can iterate and compare
Literal[...] iso, IOS-XE, junos Guaranteed one of your supported platforms
Field(ge=, le=) VLAN 5000, MTU 68 An integer inside a range you decided
Field(min_length=) Empty hostname A string with actual content

Now try each defect from the broken inventory:

from pydantic import ValidationError

for bad in [
    {"mgmt_ip": "10.1.1.300"},   # invalid octet
    {"platform": "iso"},          # transposed platform
    {"native_vlan": 5000},        # out of range
    {"mtu": 68},                  # below the floor
]:
    payload = {
        "name": "test-sw", "mgmt_ip": "10.1.1.1", "mgmt_net": "10.1.1.0/24",
        "platform": "iosxe", "mtu": 1500, "native_vlan": 10,
    }
    payload.update(bad)
    try:
        Device(**payload)
    except ValidationError as e:
        print(f"{list(bad)[0]}: {e.errors()[0]['msg']}")

Output:

mgmt_ip: value is not a valid IPv4 or IPv6 address
platform: Input should be 'ios', 'iosxe', 'nxos' or 'iosxr'
native_vlan: Input should be less than or equal to 4094
mtu: Input should be greater than or equal to 1500

All four caught, offline, in milliseconds.


๐Ÿ—๏ธ Nested Models

Real inventory isn't flat. A device has interfaces; interfaces have VLANs. Models nest by using one model as another's type.

#!/usr/bin/env python3
"""
Nested models: a device with interfaces and VLANs.
"""

from typing import Annotated, Literal, Optional

from pydantic import BaseModel, Field, IPvAnyAddress

VlanId = Annotated[int, Field(ge=1, le=4094)]


class Vlan(BaseModel):
    id: VlanId
    name: Annotated[str, Field(min_length=1, max_length=32)]


class Interface(BaseModel):
    name: str
    description: str = ""                      # default โ€” optional in the data
    mode: Literal["access", "trunk", "routed", "unused"]
    access_vlan: Optional[VlanId] = None
    ip_address: Optional[IPvAnyAddress] = None
    enabled: bool = True


class Device(BaseModel):
    name: str
    mgmt_ip: IPvAnyAddress
    platform: Literal["ios", "iosxe", "nxos", "iosxr"]
    vlans: list[Vlan] = []
    interfaces: list[Interface] = []


device = Device(
    name="access-sw-01",
    mgmt_ip="10.1.1.3",
    platform="iosxe",
    vlans=[
        {"id": 10, "name": "USERS"},
        {"id": 20, "name": "VOICE"},
    ],
    interfaces=[
        {"name": "GigabitEthernet1/0/1", "mode": "access", "access_vlan": 10},
        {"name": "GigabitEthernet1/0/2", "mode": "unused", "enabled": False},
    ],
)

print(device.vlans[0].name)              # USERS โ€” typed all the way down
print(len(device.interfaces))            # 2

Note that you passed plain dictionaries for the nested items and got back Vlan and Interface objects. Pydantic validates recursively โ€” a bad VLAN ID three levels deep is still caught, and the error tells you exactly where it was:

from pydantic import ValidationError

try:
    Device(
        name="access-sw-01", mgmt_ip="10.1.1.3", platform="iosxe",
        vlans=[{"id": 10, "name": "USERS"}, {"id": 5000, "name": "BAD"}],
    )
except ValidationError as e:
    for err in e.errors():
        print(f"  at {' -> '.join(str(p) for p in err['loc'])}: {err['msg']}")

Output:

  at vlans -> 1 -> id: Input should be less than or equal to 4094

vlans -> 1 -> id is a path. That's what you print to an operator.


๐Ÿ“‚ Validating a YAML Inventory

Now put it together on a real file. This is the pattern you'll use most.

inventory.yaml:

site: MAN1
devices:
  - name: core-sw-01
    mgmt_ip: 10.1.1.1
    platform: iosxe
    role: core
    vlans:
      - {id: 10, name: USERS}
      - {id: 20, name: VOICE}

  - name: access-sw-01
    mgmt_ip: 10.1.1.3
    platform: iosxe
    role: access
    interfaces:
      - {name: GigabitEthernet1/0/1, mode: access, access_vlan: 10}
      - {name: GigabitEthernet1/0/2, mode: unused, enabled: false}

models.py:

#!/usr/bin/env python3
"""
Inventory models and a loader that fails fast with readable errors.
"""

import sys
from typing import Annotated, Literal, Optional

import yaml
from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress, ValidationError

VlanId = Annotated[int, Field(ge=1, le=4094)]


class Vlan(BaseModel):
    model_config = ConfigDict(extra="forbid")

    id: VlanId
    name: Annotated[str, Field(min_length=1, max_length=32)]


class Interface(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: str
    description: str = ""
    mode: Literal["access", "trunk", "routed", "unused"]
    access_vlan: Optional[VlanId] = None
    ip_address: Optional[IPvAnyAddress] = None
    enabled: bool = True


class Device(BaseModel):
    model_config = ConfigDict(extra="forbid")

    name: str
    mgmt_ip: IPvAnyAddress
    platform: Literal["ios", "iosxe", "nxos", "iosxr"]
    role: Literal["core", "distribution", "access", "edge"]
    vlans: list[Vlan] = []
    interfaces: list[Interface] = []


class Inventory(BaseModel):
    model_config = ConfigDict(extra="forbid")

    site: Annotated[str, Field(pattern=r"^[A-Z]{3}\d$")]
    devices: Annotated[list[Device], Field(min_length=1)]


def load_inventory(path: str) -> Inventory:
    """
    Load and validate an inventory file.

    Exits with a non-zero status and an operator-readable report if the
    file is malformed. Returns a fully typed Inventory on success.
    """
    try:
        with open(path) as handle:
            raw = yaml.safe_load(handle)
    except FileNotFoundError:
        sys.exit(f"โœ— Inventory not found: {path}")
    except yaml.YAMLError as exc:
        sys.exit(f"โœ— {path} is not valid YAML:\n  {exc}")

    try:
        return Inventory.model_validate(raw)
    except ValidationError as exc:
        print(f"โœ— {path} failed validation ({exc.error_count()} problems):\n")
        for err in exc.errors():
            location = " -> ".join(str(part) for part in err["loc"])
            print(f"  {location}")
            print(f"      {err['msg']}")
            print(f"      you supplied: {err['input']!r}\n")
        sys.exit(1)


if __name__ == "__main__":
    inventory = load_inventory("inventory.yaml")
    print(f"โœ“ {inventory.site}: {len(inventory.devices)} devices validated")
    for device in inventory.devices:
        print(f"  {device.name:<16} {device.mgmt_ip}  {device.platform}")

Output on a good file:

โœ“ MAN1: 2 devices validated
  core-sw-01       10.1.1.1  iosxe
  access-sw-01     10.1.1.3  iosxe

Output on the broken inventory from earlier:

โœ— inventory.yaml failed validation (4 problems):

  devices -> 0 -> mgmt_ip
      value is not a valid IPv4 or IPv6 address
      you supplied: '10.1.1.300'

  devices -> 1 -> platform
      Field required
      you supplied: {'name': 'dist-sw-01', 'mgmt_ip': '10.1.1.2', 'platfrom': 'ios', ...}

  devices -> 1 -> platfrom
      Extra inputs are not permitted
      you supplied: 'ios'

  devices -> 2 -> platform
      Input should be 'ios', 'iosxe', 'nxos' or 'iosxr'
      you supplied: 'iso'

Every defect, in one report, before anything connected to anything.


โœ๏ธ Custom Rules with Validators

Field types cover the general cases. Your naming standard and your design rules are yours, and they go in validators.

Field validators โ€” one field at a time

import re

from pydantic import BaseModel, field_validator

HOSTNAME_PATTERN = re.compile(r"^[a-z]{3}\d-(core|dist|acc)-sw-\d{2}$")


class Device(BaseModel):
    name: str

    @field_validator("name")
    @classmethod
    def enforce_naming_standard(cls, value: str) -> str:
        if not HOSTNAME_PATTERN.match(value):
            raise ValueError(
                f"'{value}' does not match the naming standard "
                "<site><n>-<role>-sw-<nn>, e.g. man1-core-sw-01"
            )
        return value

Two rules worth internalising: the method is a @classmethod, and you raise a plain ValueError โ€” Pydantic catches it and folds it into the ValidationError report with the field location attached.

Validators can also normalise. Returning a changed value replaces the input:

    @field_validator("name")
    @classmethod
    def normalise_case(cls, value: str) -> str:
        return value.strip().lower()   # "  MAN1-Core-SW-01 " -> "man1-core-sw-01"

Order Matters

Multiple validators on one field run in definition order. Put normalisation before enforcement, or you'll reject data you were about to fix.

Model validators โ€” rules that span fields

Some rules can't be checked one field at a time. An access port needs an access VLAN; a routed port must not have one.

from typing import Literal, Optional

from pydantic import BaseModel, model_validator


class Interface(BaseModel):
    name: str
    mode: Literal["access", "trunk", "routed", "unused"]
    access_vlan: Optional[int] = None
    ip_address: Optional[str] = None

    @model_validator(mode="after")
    def check_mode_consistency(self):
        if self.mode == "access" and self.access_vlan is None:
            raise ValueError(f"{self.name}: access ports require an access_vlan")
        if self.mode == "routed" and self.ip_address is None:
            raise ValueError(f"{self.name}: routed ports require an ip_address")
        if self.mode == "routed" and self.access_vlan is not None:
            raise ValueError(f"{self.name}: routed ports cannot have an access_vlan")
        return self

mode="after" runs once every field has already been validated and converted, so you're comparing real values. Return self.

This is where a Pydantic model starts doing something a schema can't: it encodes design rules, not just shapes.

Cross-referencing within the inventory

A model validator on the parent can check that children agree with each other โ€” the check that catches the most real-world mistakes:

class Device(BaseModel):
    name: str
    vlans: list[Vlan] = []
    interfaces: list[Interface] = []

    @model_validator(mode="after")
    def interfaces_reference_declared_vlans(self):
        declared = {vlan.id for vlan in self.vlans}
        for interface in self.interfaces:
            if interface.access_vlan and interface.access_vlan not in declared:
                raise ValueError(
                    f"{self.name}/{interface.name} uses VLAN {interface.access_vlan}, "
                    f"which is not declared on this device (declared: {sorted(declared)})"
                )
        return self

An interface assigned to a VLAN that was never created is a genuinely common outage. It costs you nine lines to make it impossible.


๐Ÿ”’ Strictness and Unknown Keys

extra="forbid" deserves its own section, because for inventory files it is the single highest-value setting in this tutorial.

By default Pydantic ignores keys it doesn't recognise. Your platfrom typo silently vanishes, the real platform field is reported missing, and โ€” if platform happened to have a default โ€” nothing is reported at all. You'd deploy with the wrong driver and no warning.

from pydantic import BaseModel, ConfigDict


class Device(BaseModel):
    model_config = ConfigDict(extra="forbid")
    # ...
Setting Behaviour Use for
extra="ignore" (default) Unknown keys silently dropped API responses you don't control
extra="forbid" Unknown keys raise an error Your own inventory and intent files
extra="allow" Unknown keys kept as attributes Pass-through data you must preserve

The rule of thumb: forbid on data you author, ignore on data you receive. You want to be told about your own typos. You don't want your automation to break because a vendor added a field to an API response.

For the inverse problem โ€” a value that's the right shape but the wrong type, like mtu: "1500" โ€” use strict mode where lossless coercion isn't acceptable:

from typing import Annotated
from pydantic import Field

# Reject the string "1500"; require a real YAML integer
mtu: Annotated[int, Field(strict=True)]

Use this sparingly. With YAML in particular, coercion is usually a feature.


๐ŸŒ Validating API Responses

The same model validates JSON from a controller. This is the part that pays off the JSON tutorial.

#!/usr/bin/env python3
"""
Validate a controller's device inventory response.
"""

from typing import Literal

import requests
from pydantic import BaseModel, ConfigDict, Field, IPvAnyAddress, ValidationError


class ControllerDevice(BaseModel):
    # Controllers add fields between releases โ€” ignore what we don't model
    model_config = ConfigDict(extra="ignore", populate_by_name=True)

    hostname: str
    management_ip: IPvAnyAddress = Field(alias="managementIpAddress")
    platform: str = Field(alias="platformId")
    reachability: Literal["Reachable", "Unreachable", "Ping Reachable"]
    uptime_seconds: int = Field(alias="upTime", default=0)


class ControllerResponse(BaseModel):
    model_config = ConfigDict(extra="ignore")

    response: list[ControllerDevice]


def fetch_devices(base_url: str, token: str) -> list[ControllerDevice]:
    reply = requests.get(
        f"{base_url}/dna/intent/api/v1/network-device",
        headers={"X-Auth-Token": token},
        timeout=30,
    )
    reply.raise_for_status()

    try:
        parsed = ControllerResponse.model_validate_json(reply.text)
    except ValidationError as exc:
        raise RuntimeError(
            f"Controller returned data we don't understand:\n{exc}"
        ) from exc

    return parsed.response

Two details doing real work here:

Field(alias=...) lets the API keep its managementIpAddress camelCase while your code uses a sane management_ip. The ugly naming stops at the boundary instead of spreading through your codebase. (populate_by_name=True means you can still construct the model with management_ip=... in your own tests.)

model_validate_json() parses and validates in one step, which is faster than json.loads() followed by model_validate() because Pydantic v2's parser is implemented in Rust.

The wider point: an API response is untrusted input. A controller that returns null for a management IP will otherwise hand you a None that travels four function calls before failing somewhere unhelpful. Validate it where it arrives.


๐ŸŽจ Handing a Validated Model to Jinja2

This is the join between this tutorial and the next one. Jinja2 renders from a validated model, not a raw dictionary.

#!/usr/bin/env python3
"""
Render device configuration from a validated model.
"""

from jinja2 import Environment, FileSystemLoader, StrictUndefined

from models import load_inventory

env = Environment(
    loader=FileSystemLoader("templates"),
    trim_blocks=True,
    lstrip_blocks=True,
    undefined=StrictUndefined,   # unknown variable = error, not blank
)

inventory = load_inventory("inventory.yaml")
template = env.get_template("switch.j2")

for device in inventory.devices:
    config = template.render(device=device, site=inventory.site)
    with open(f"output/{device.name}.cfg", "w") as handle:
        handle.write(config)
    print(f"โœ“ rendered output/{device.name}.cfg")

templates/switch.j2:

hostname {{ device.name }}
!
{% for vlan in device.vlans %}
vlan {{ vlan.id }}
 name {{ vlan.name }}
{% endfor %}
!
{% for interface in device.interfaces %}
interface {{ interface.name }}
{% if interface.description %}
 description {{ interface.description }}
{% endif %}
{% if interface.mode == 'access' %}
 switchport mode access
 switchport access vlan {{ interface.access_vlan }}
{% elif interface.mode == 'unused' %}
 description UNUSED
 switchport mode access
 switchport access vlan 999
{% endif %}
{% if not interface.enabled %}
 shutdown
{% else %}
 no shutdown
{% endif %}
!
{% endfor %}
end

Note that the template uses attribute access โ€” device.name, interface.access_vlan โ€” because it's being handed objects. It reads better than device['name'], and combined with StrictUndefined a mistyped attribute in the template becomes a loud error rather than a blank line in a config file.

The model_dump() Gotcha

If you need a plain dictionary โ€” to pass to a library, or to write out as JSON โ€” model_dump() returns Python objects, not JSON-safe values. An IPvAnyAddress field comes back as an ipaddress.IPv4Address, which json.dumps() cannot serialise.

device.model_dump()                 # {'mgmt_ip': IPv4Address('10.1.1.1'), ...}
device.model_dump(mode="json")      # {'mgmt_ip': '10.1.1.1', ...}  โ† for JSON
device.model_dump_json()            # '{"mgmt_ip": "10.1.1.1", ...}' โ† a string

Rendering with Jinja2 works either way because Jinja2 calls str() on values. Writing JSON does not. Reach for mode="json".


โš–๏ธ Pydantic or JSON Schema?

You learned JSON Schema in the JSON tutorial. Both validate data, and it's fair to ask which to use. They solve adjacent problems.

JSON Schema Pydantic
Written in JSON/YAML (language-neutral) Python
Shareable with non-Python tools โœ… Yes โŒ No
Gives you typed objects โŒ No โ€” validates a dict, returns nothing โœ… Yes
Custom logic โš ๏ธ Limited to what the spec expresses โœ… Arbitrary Python
Editor autocomplete โŒ No โœ… Yes
Publishable as a contract โœ… That's its purpose โœ… Via model_json_schema()

Use JSON Schema when the schema is a contract that has to be read by something other than your Python code โ€” a CI linter, a vendor's API documentation, another team's Go service, a VS Code YAML plugin giving your operators inline hints.

Use Pydantic when the data is entering your application and you want to work with it afterwards. Which, in an automation script, is nearly always.

You don't have to choose. A Pydantic model will generate the schema for you:

import json
from models import Inventory

with open("inventory.schema.json", "w") as handle:
    json.dump(Inventory.model_json_schema(), handle, indent=2)

Commit that file, point your editor's YAML plugin at it, and your operators get autocomplete and inline errors while they're editing the inventory โ€” from the same model that enforces the rules at runtime. One definition, two places it pays off.


๐Ÿ”‘ Bonus: Settings and Credentials

pydantic-settings applies the same validation to configuration and environment variables, which pairs directly with the credential management tutorial:

pip install pydantic-settings
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class AutomationSettings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="NETAUTO_", env_file=".env")

    username: str
    password: SecretStr
    vault_url: str = "https://vault.example.internal"
    max_workers: int = 10


settings = AutomationSettings()   # reads NETAUTO_USERNAME, NETAUTO_PASSWORD, ...

print(settings.password)                    # **********  โ€” safe to log
print(settings.password.get_secret_value()) # the real value, only where needed

A missing NETAUTO_PASSWORD now fails at startup with a clear message rather than producing a confusing authentication failure on device seven. And SecretStr means an accidental print(settings) or a logged traceback shows ********** instead of your enable password.


๐Ÿ”„ Pydantic v2 vs v1: What Changed

If you find an example online that doesn't work, check it against this table.

v1 v2 Notes
@validator("field") @field_validator("field") Also needs @classmethod
@root_validator @model_validator(mode="after") Return self, not a dict
class Config: model_config = ConfigDict(...) Now an attribute
.dict() .model_dump()
.json() .model_dump_json()
Model.parse_obj(d) Model.model_validate(d)
Model.parse_raw(s) Model.model_validate_json(s)
.schema() .model_json_schema()
conint(ge=1, le=4094) Annotated[int, Field(ge=1, le=4094)] conint still works, Annotated preferred
Optional[X] implied optional Optional[X] = None required v2 no longer infers a None default

That last row catches people out most often. In v2, Optional[int] means "may be None", not "may be omitted". If the field can be left out of the data, give it a default.


๐Ÿญ Production Patterns

1. One models module, imported everywhere

Define models in models.py and import them. The model is your data contract; having two definitions of a device is how they drift apart.

2. Validate once, at the edge

Validate immediately after loading a file or receiving a response. Everything downstream receives typed objects and should never re-check. If you find yourself writing if device.mtu is None deep in the code, the model was too loose.

3. Collect all errors, then exit

Pydantic reports every problem in one pass. Print the lot. An operator fixing four things in one edit is faster than four rounds of run-fix-run.

4. Version your models alongside your data

When the model gains a required field, existing inventory files become invalid. Either give the field a default or bump a version field in the data and handle both. Silent breakage at change time is worse than a schema you have to maintain.

5. Test your models

Models are logic and deserve the treatment the testing tutorial describes:

import pytest
from pydantic import ValidationError
from models import Interface


def test_access_port_requires_vlan():
    with pytest.raises(ValidationError, match="require an access_vlan"):
        Interface(name="Gi1/0/1", mode="access")


def test_routed_port_rejects_access_vlan():
    with pytest.raises(ValidationError, match="cannot have an access_vlan"):
        Interface(name="Gi1/0/1", mode="routed", ip_address="10.1.1.1", access_vlan=10)

Every rule you encode is a rule you can prove still holds.

6. Don't model what you don't use

It's tempting to model all sixty fields a controller returns. Model the ones you actually read, set extra="ignore", and let the rest pass by. A smaller model is a smaller maintenance surface.


๐Ÿ“š Additional Resources


๐ŸŽฏ Key Takeaways

  • โœ… Validate at the boundary โ€” Bad data should fail on your laptop, not on a device
  • โœ… The model is the specification โ€” Annotations document what your data must be
  • โœ… Network types catch network errors โ€” IPvAnyAddress and Literal reject what str accepts
  • โœ… extra="forbid" on your own files โ€” Typos in keys are the defect you'll actually hit
  • โœ… Validators encode design rules โ€” Not just shapes, but the rules your network runs on
  • โœ… One model, two formats โ€” The same class validates YAML intent and JSON responses
  • โœ… Errors name a path โ€” devices -> 1 -> platform is something an operator can fix
  • โœ… Pydantic v2 syntax only โ€” v1 examples will not run

๐ŸŽ“ Next Steps

You can now prove your data is correct before using it. Turn it into configuration:

  1. Jinja2 Configuration Templates (Recommended Next)
  2. Render configs from the validated models you just built
  3. Attribute access and StrictUndefined for safe templates

  4. Health Checks and Pre-Flight Validation

  5. Data validation is the first pre-flight gate; add the device-state ones

  6. Credential Management

  7. Apply pydantic-settings and SecretStr to your secrets handling

  8. Testing Network Automation

  9. Prove your validation rules hold as the model evolves

Remember: A dictionary tells you what someone typed. A model tells you what's true. Automation should only ever act on the second.

โ† Back to JSON Tutorial | Continue to Jinja2 Tutorial โ†’

Need help applying this in a live Cisco environment?

This guide is part of the Nautomation Prime Foundation and stays free to read, share, and reuse. If you want the pattern implemented, governed, or adapted for your estate, that is paid engineering work — start a discovery conversation or review how Nautomation Prime delivers engagements. If you are a registered UK charity or CIC, there is a free and low-cost route instead.