Skip to content
datahacks.dev
YAML

Validating YAML Configuration With pydantic Models

Load YAML, validate it with pydantic models and get every error with its path. The Norway problem caught, extra keys rejected and ranges enforced.

7 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the YAML logo.

Loading a YAML file tells you it is YAML. It does not tell you it is the configuration your program needs. A misspelled key is ignored, a port written as a word survives until it is used, a boolean where a string belongs passes through. The cheapest fix is a schema checked at load time, and in Python the most convenient way to write one is a pydantic model. This post builds one for a small configuration, runs it on a good and a bad file, and shows the errors it produces.

The models

A configuration with an environment name, a list of servers and a timeout:

config.pyPython
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field

class Server(BaseModel):
    model_config = ConfigDict(extra="forbid")
    host: str
    port: int = Field(ge=1, le=65535)
    tls: bool = False

class Config(BaseModel):
    model_config = ConfigDict(extra="forbid")
    env: Literal["dev", "staging", "prod"]
    servers: list[Server]
    timeout: float = 30.0

The class bodies read like a specification. env must be one of three strings. servers is a list of Server, each with a string host, an integer port between 1 and 65535 and an optional boolean tls. timeout is a number with a default. extra="forbid" makes any key not declared an error, in both models.

A good file

YAML
env: prod
servers:
  - {host: a.example, port: 443, tls: true}
timeout: 12
Python
Config.model_validate(yaml.safe_load(good))
Text
env='prod' servers=[Server(host='a.example', port=443, tls=True)] timeout=12.0

The result is a typed object, config.servers[0].port is an int, and timeout was converted from the integer 12 to the float 12.0. Code downstream can rely on the types. (pydantic converts compatible values by default, which is called lax mode; ConfigDict(strict=True) switches it off when you want 12 rejected for a float field.)

A bad file

YAML
env: production
servers:
  - {host: a.example, port: "https"}
  - {host: b.example, port: 70000, tls: maybe, extra_key: 1}
timeout: soon
retries: 3

Validating it:

Python
try:
    Config.model_validate(yaml.safe_load(bad))
except ValidationError as e:
    print(e.error_count(), "errors")
    for err in e.errors():
        print(" ", ".join(str(p) for p in err["loc"]), "->", err["msg"])
Text
7 errors
  env -> Input should be 'dev', 'staging' or 'prod'
  servers.0.port -> Input should be a valid integer, unable to parse string as an integer
  servers.1.port -> Input should be less than or equal to 65535
  servers.1.tls -> Input should be a valid boolean, unable to interpret input
  servers.1.extra_key -> Extra inputs are not permitted
  timeout -> Input should be a valid number, unable to parse string as a number
  retries -> Extra inputs are not permitted

Seven errors from one run. That is the main advantage over writing checks by hand: pydantic collects every error instead of stopping at the first, and each one has a location path (servers.1.port is the port of the second server) and a plain message. That output can go straight into an error message for the person who edited the file. The misspelled-key class of mistakes shows up as "Extra inputs are not permitted", which is what extra="forbid" is for. Without it, extra_key and retries would have been silently ignored, and the typo tsl: true would have left TLS off.

It catches the YAML type traps

The type declarations catch the surprises that YAML's type guessing introduces. A country code field:

Python
class C(BaseModel):
    country: str
C.model_validate(yaml.safe_load("country: NO"))
Text
country: NO -> Input should be a valid string | input: False

PyYAML loaded the unquoted NO as the boolean False; the field is declared str; pydantic rejected it and reported the input it received, False. The error is a loud, early signal that the file needs quotes (the Norway problem and YAML boolean surprises). Compare the failure mode without validation: a False flowing into code that expected a code, found when a customer in Norway cannot be selected.

The same applies to versions loaded as floats, identifiers with leading zeros loaded as numbers and times loaded as integers. Declare them str, and the loader tells you. By default pydantic's lax mode does not convert numbers to strings, so a str field given 3.1 for a version written 3.10 is rejected, which is the behavior you want here (YAML version numbers turning into floats).

Useful features

Defaults and optional fields. timeout: float = 30.0 makes the key optional. Use str | None = None for fields that may be absent or null.

Constraints. Field(ge=1, le=65535), min_length, pattern for strings, Literal[...] for enumerations. Put the constraints in the model, and the error messages describe them.

Custom validators. For rules involving several fields (a tls server must have port 443), use @model_validator. For parsing special strings ("30s" into seconds), use @field_validator.

Nested models and lists validate recursively, with paths that show where.

Secrets. SecretStr hides a value in logs and error output, which matters for passwords in configuration.

Environment overrides. The companion package pydantic-settings reads values from environment variables with the same models, which makes layering file and environment values straightforward (environment variable substitution in YAML config).

Where to put the validation

Validate once, at the boundary where the file is loaded, and pass the typed object to the rest of the program. Put the load function and the model in one module that the whole application imports:

Python
def load_config(path) -> Config:
    with open(path, encoding="utf-8") as f:
        data = yaml.safe_load(f) or {}
    return Config.model_validate(data)

A failing load exits with the full list of errors at start-up, not hours later in a code path nobody exercised. In CI, add a test that loads the example and production configuration files, so a bad edit fails the pull request. The same model can generate a JSON Schema (Config.model_json_schema()) for editors and for tools that validate outside Python (validating config YAML with JSON Schema).

Showing errors to the person who wrote the file

The error list is the raw material for a good message, and a little formatting makes it better. Print the file name, then one line per error with the path in a form the author recognizes (servers[1].port) and the message. Add the received value when it is short, which pydantic includes in each error as input; for a bad port, "got 70000" is more useful than "less than or equal to 65535" alone. Group errors by top-level section when the list is long. And exit with a nonzero status so scripts and CI notice.

If your users want line numbers, there are two routes. Compose the YAML into a node tree with yaml.compose, which keeps the start mark of every node, and look up the path from each error to find the line. Or load with ruamel.yaml round-trip mode, whose containers expose lc.line and lc.col for keys and items. Either costs thirty lines and turns "servers.1.port is invalid" into "line 7, column 30: port is invalid", which is worth it for files that non-programmers edit.

A last tip is to validate example files in the documentation. Keep an example.yaml that you ship in the repository and load it in a test with the model. When someone adds a field to the model and forgets the example, the test fails, and the example stays truthful.

The cluster hub is reading and writing YAML in Python safely. The loading step is in loading YAML in Python with PyYAML safe_load. The model features are documented in the pydantic documentation and its validation errors reference. Check how a value loads in the YAML to JSON tool.

When pydantic validation is not enough

Other languages read the file. A Python model validates for Python. Export the JSON Schema and validate in CI for every consumer.

The file is huge. Validation of thousands of records is fast in pydantic 2 and still not the right tool for bulk data.

The constraints are external. A port that must be free, a host that must resolve and a file that must exist are checks against the world. Do them in a separate start-up step with clear messages.

You need the original text. Validated objects have lost comments and line numbers, so errors cannot point to a line. If line numbers matter to your users, validate the loaded nodes with a library that tracks marks, or map paths to lines by composing the YAML tree.

Also inScripting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.