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.
How Schema Validation Works
Section titled “How Schema Validation Works”- YAML syntax — every YAML file (
.yamlor.yml) in the given paths is parsed. Errors such as wrong indentation are reported with line and column. - Schema — every file is validated on its own against the schema with Yamale.
- 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.
Schema Structure
Section titled “Schema Structure”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]Common Keywords
Section titled “Common Keywords”All validators accept the following keywords:
| Keyword | Default | Description |
|---|---|---|
required | True | Whether the key must be present. required=False makes it optional. |
none | True | Whether 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.
Validators
Section titled “Validators”The validators below are those of Yamale 6, which nac-validate uses. See the Yamale documentation for all details.
Scalars
Section titled “Scalars”| Validator | Accepts | Keywords |
|---|---|---|
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 patterns | name (used in error messages), ignore_case, multiline, dotall |
int() | Integers | min, max |
num() | Integers and floats | min, 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)Lists and Maps
Section titled “Lists and Maps”| Validator | Accepts | Keywords |
|---|---|---|
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 include | strict |
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.
Combining Validators
Section titled “Combining Validators”| Validator | Accepts | Keywords |
|---|---|---|
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 one | allow_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 prefixesprefixes: subset(ip(), allow_empty=True)subset() ignores required=False: the key is optional only with allow_empty=True.
Strict Mode
Section titled “Strict Mode”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 validatedcustom: any(required=False)nac-validate Extensions
Section titled “nac-validate Extensions”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 Keys
Section titled “Primary Keys”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 2data/nodes.yaml: fabric.nodes: Primary key 'id' in item 3 must be a single value, not a listdata/nodes.yaml: fabric.nodes: Duplicate primary key id=101 in items 0, 1data/interfaces.yaml: fabric.interfaces: Duplicate primary key node_id=101, name=eth1/1 in items 0, 2Values 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:
fabric: nodes: - id: 102 name: leaf-102 role: leaffabric: nodes: - id: 102 name: spine-102 role: spinedata: 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.
Error Messages
Section titled “Error Messages”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 missingList 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.