Skip to content

Schema Validation

Syntactic validation checks that every YAML file is well-formed and that its structure matches a schema: which keys exist, which ones are mandatory, and which types and values they accept. It catches typos in key names, missing mandatory attributes, wrong types and out-of-range values — before the data model reaches any downstream tooling.

The schema is written in Yamale syntax, which nac-validate extends with additional constraints such as primary keys. It is passed with -s/--schema and defaults to .schema.yaml in the current directory. Without a schema, only the YAML syntax is checked.

  1. YAML syntax — every YAML file (.yaml or .yml) in the given paths is parsed. Errors such as wrong indentation are reported with line and column.
  2. Schema — every file is validated on its own against the schema with Yamale.
  3. Schema extensions — once all files passed, the extensions are checked, first in every file and then, where needed, on the data merged from all files.

Semantic validation only runs if syntactic validation passed. Syntactic errors are reported with exit code 2.

Because each file is validated individually, a field marked as required must be present in every file that contains its parent. If the data of one object is spread across several files, its fields must be optional (required=False). This is why most fields in Network as Code schemas are optional, except for identifiers such as name.

A schema file consists of one or more YAML documents separated by ---. The first document describes the root of the data, all following documents define reusable includes, referenced by name with include():

---
fabric: include('fabric', required=False)
---
fabric:
name: regex('^[a-zA-Z0-9_.-]{1,64}$', required=False)
nodes: list(include('node'), primary_key='id', required=False)
interfaces: list(include('interface'), primary_key='node_id,name', required=False)
node:
id: int(min=101, max=4000)
name: str(max=63)
role: enum('leaf', 'spine')
mgmt_ip: ip(version=4, required=False)
interface:
node_id: int()
name: regex('^eth\d+/\d+$')
description: str(exclude='"', required=False)
vlans: list(int(min=1, max=4094), required=False)

Every key maps to a validator, and nested maps can be written inline or moved to an include. Includes can reference other includes, and themselves for recursive structures. A valid data file for the schema above:

fabric:
name: FABRIC1
nodes:
- id: 101
name: leaf-101
role: leaf
mgmt_ip: 10.0.0.101/24
interfaces:
- node_id: 101
name: eth1/1
vlans: [10, 20]

All validators accept the following keywords:

KeywordDefaultDescription
requiredTrueWhether the key must be present. required=False makes it optional.
noneTrueWhether an optional key may be set to null (e.g. name: without a value). none=False rejects null values. A required key never accepts null.

An optional key set to null is treated as not set, so its validator and any extensions are skipped.

The validators below are those of Yamale 6, which nac-validate uses. See the Yamale documentation for all details.

ValidatorAcceptsKeywords
str()Strings (not numbers or booleans)min, max (length), equals, starts_with, ends_with, matches (regex), exclude (characters not allowed), ignore_case (for equals, starts_with, ends_with and exclude)
regex(pattern, ...)Strings matching at least one of the patternsname (used in error messages), ignore_case, multiline, dotall
int()Integersmin, max
num()Integers and floatsmin, max
bool()true and false
enum(value, ...)One of the given constants, e.g. enum('leaf', 'spine') or enum(1, 2, 3)
ip()IPv4 and IPv6 addresses, with or without prefix length (10.0.0.1, 10.0.0.0/24)version (4 or 6)
mac()MAC addresses (00:11:22:33:44:55, 0011.2233.4455, …)
semver()Semantic versions (1.2.3)
day()Dates (2026-11-05)min, max
timestamp()Date and time (2026-11-05 10:00:00)min, max
null()Null values only

Regex patterns match from the start of the string, but not necessarily to its end — anchor them with ^...$ to match the entire value. Validator arguments are parsed like Python string literals: escape sequences such as \d or \. are kept as written ('^eth\d+/\d+$'), but a literal backslash in the value needs four backslashes in the pattern ('^a\\\\b$' matches a\b).

name: regex('^[a-zA-Z0-9_.-]{1,64}$')
hostname: str(min=1, max=63, exclude=' ')
mtu: int(min=576, max=9216, required=False)
admin_state: enum('up', 'down', required=False)
loopback: ip(version=4)
ValidatorAcceptsKeywords
list(validator, ...)Lists whose items match at least one of the validators. list() accepts any items.min, max (number of items), primary_key
map(validator, ...)Maps whose values match at least one of the validators. map() accepts any keys and values.min, max (number of keys), key (validator for the keys)
include('name')Data matching the named includestrict
nodes: list(include('node'), min=1)
vlans: list(int(min=1, max=4094))
labels: map(str(), key=regex('^[a-z_]+$'), required=False)

map() is meant for maps with arbitrary keys. Maps with a fixed set of keys are defined as an include or nested inline in the schema.

ValidatorAcceptsKeywords
any(validator, ...)Data matching at least one of the validators
subset(validator, ...)A list or map where every item matches one of the validators; a single value is treated as a list of oneallow_empty
# A VLAN ID or the keyword 'all'
vlan: any(int(min=1, max=4094), enum('all'), required=False)
# Either a single prefix or a list of prefixes
prefixes: subset(ip(), allow_empty=True)

subset() ignores required=False: the key is optional only with allow_empty=True.

By default, keys that are not defined in the schema are reported as Unexpected element. This catches misspelled keys, which would otherwise silently be ignored by downstream tooling.

--non-strict turns this off for the whole data, and include('name', strict=False) for the data below a single include — for example, for parts of the data model consumed by other tools. Untyped data can also be allowed with any():

---
fabric: include('fabric', required=False)
# Data used by other tools, not validated
custom: any(required=False)

nac-validate adds keywords to the standard Yamale validators. Plain Yamale accepts and ignores them, so a schema using them still works with Yamale and other tools, which then just don’t enforce them.

  • Extensions are checked once the data passed Yamale validation, so they only report errors in data that is otherwise valid.

  • An extension keyword on a validator that does not support it, or with an invalid value, fails loading the schema (exit code 3):

    ERROR - Invalid schema: node.name: primary_key is only supported on list()
  • A keyword known neither to Yamale nor to nac-validate, such as a misspelled one, is logged as a warning and ignored:

    WARNING - Unknown schema keyword 'requird' at 'node.name' is ignored

primary_key on a list() of maps names a field that every item must set to a single value — not a map or a list — that is unique within the list. It is available in nac-validate 2.1.0 and later.

nodes: list(include('node'), primary_key='id', required=False)

A composite key names several comma-separated fields, whose combination must be unique. This allows, for example, the same interface name on different nodes:

interfaces: list(include('interface'), primary_key='node_id,name', required=False)

The following errors are reported:

data/nodes.yaml: fabric.nodes: Primary key 'id' missing in item 2
data/nodes.yaml: fabric.nodes: Primary key 'id' in item 3 must be a single value, not a list
data/nodes.yaml: fabric.nodes: Duplicate primary key id=101 in items 0, 1
data/interfaces.yaml: fabric.interfaces: Duplicate primary key node_id=101, name=eth1/1 in items 0, 2

Values of different types are distinct, so 1 and "1" do not collide.

Primary keys are checked in every file and, once all files passed, on the data merged from all files. When merging, list items with the same key are only merged if all their shared fields have the same values (see Merging YAML). Therefore, the same key can be used in several files to spread an object across them, but a key used with conflicting values in two files remains a duplicate:

nodes.yaml
fabric:
nodes:
- id: 102
name: leaf-102
role: leaf
more_nodes.yaml
fabric:
nodes:
- id: 102
name: spine-102
role: spine
data: fabric.nodes: Duplicate primary key id=102 across files (with conflicting values that prevent merging)

Unlike a semantic rule checking for unique keys, a primary key is part of the schema, so it applies to every list using the include and doesn’t need to be maintained separately.

Schema errors are reported per file, with the path of the invalid value in the data:

• data/interfaces.yaml: fabric.interfaces.[name=Ethernet1/2].name: 'Ethernet1/2' is not a regex match.
• data/interfaces.yaml: fabric.interfaces.[name=Ethernet1/2].vlans: 5000 is greater than 4094
• data/nodes.yaml: fabric.nodes.[name=leaf-102].role: 'border' not in ('leaf', 'spine')
• data/more_nodes.yaml: fabric.nodes.[id=102].foo: Unexpected element
• data/more_nodes.yaml: fabric.nodes.[id=102].role: Required field missing

List items are shown by one of their fields instead of their index: name if set, otherwise id, otherwise the first scalar field of the item.