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.

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:
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.0The 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
env: prod
servers:
- {host: a.example, port: 443, tls: true}
timeout: 12Config.model_validate(yaml.safe_load(good))env='prod' servers=[Server(host='a.example', port=443, tls=True)] timeout=12.0The 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
env: production
servers:
- {host: a.example, port: "https"}
- {host: b.example, port: 70000, tls: maybe, extra_key: 1}
timeout: soon
retries: 3Validating it:
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"])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 permittedSeven 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:
class C(BaseModel):
country: str
C.model_validate(yaml.safe_load("country: NO"))country: NO -> Input should be a valid string | input: FalsePyYAML 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:
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.
Related reading
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.


