Input & UX

Loading YAML configs safely in CLI apps

Load and validate YAML configuration files safely in Python CLI apps using pyyaml with Pydantic schema validation and secure load() best practices.

Updated

YAML is the default config format for most CLIs because it is readable and supports nesting. But the default way to parse it in Python — yaml.load() — can execute arbitrary code embedded in the file. If your CLI reads a config that a user, a CI system, or a teammate can write, parsing it unsafely turns a config file into a remote-code-execution vector. This page shows the one-line fix and the validation layer that turns a raw dict into a checked, typed config object.

TL;DR

  • Always call yaml.safe_load(). Never yaml.load(), yaml.FullLoader, or yaml.UnsafeLoader on files you didn't generate.
  • safe_load refuses the YAML tags (!!python/object/...) that construct arbitrary Python objects — so a malicious file can't run code through your parser.
  • A parsed dict is still untrusted data: validate it against a Pydantic v2 model with extra="forbid" to catch wrong types, missing required keys, and typo'd extra keys.
  • Turn Pydantic's ValidationError into a short, line-oriented message so the user knows exactly what to fix.
Always use safe_load Always use safe_load config.yaml untrusted yaml.safe_load() plain dict Pydantic validate ✓ safe yaml.load() / FullLoader can construct arbitrary objects ✗ code execution risk

Why safe_load, and what load actually does

PyYAML's full loader implements YAML tags that construct native Python objects. The tag !!python/object/apply:os.system tells the loader to call os.system with the given argument while parsing. So this is a valid YAML document:

# DO NOT load this with yaml.load()
!!python/object/apply:os.system ['echo pwned']

yaml.load(text) — and FullLoader/UnsafeLoader — will execute that os.system call. The attacker doesn't need your code to do anything; the parse step itself is the exploit. This is the YAML analogue of pickle.loads on untrusted input. CVE history is full of CLIs that shipped yaml.load on user config.

yaml.safe_load() uses SafeLoader, which only constructs standard scalars and containers — strings, ints, floats, bools, None, lists, and dicts. Encounter a !!python/... tag and it raises ConstructorError instead of executing anything. Here is the difference, runnable:

import yaml

danger = "!!python/object/apply:os.system ['echo pwned']\n"
try:
    yaml.safe_load(danger)
except yaml.YAMLError as e:
    print("safe_load refused unsafe tag:", type(e).__name__)
# -> safe_load refused unsafe tag: ConstructorError

The rule is absolute: for any file outside your own build process, use safe_load. There is no flag combination that makes the unsafe loaders acceptable for user-supplied YAML.

Loading and validating: the complete pattern

Parsing safely gets you a dict, but that dict is still arbitrary: it might be missing a required key, have port: "five thousand", or contain a typo like prot:. Push it through a schema. This program runs end-to-end and is the pattern you should copy:

From YAML text to a typed object A YAML file is parsed with safe load into plain Python data, then validated into a typed model, with any failure reported as a configuration error. From YAML text to a typed object config.yaml untrusted text safe_load plain dicts and lists only Model validate types, ranges, required keys Settings or a clear error, exit 78 parse validate use safe_load is the security boundary; the model is the correctness boundary. You need both.
from __future__ import annotations
from pathlib import Path
import yaml
from pydantic import BaseModel, ConfigDict, ValidationError


class Settings(BaseModel):
    model_config = ConfigDict(extra="forbid")  # reject unknown keys
    host: str                                   # required
    port: int = 5432
    timeout: int = 10
    retries: int = 3
    verbose: bool = False
    tags: list[str] = []


def load_yaml(path: Path) -> dict:
    text = path.read_text(encoding="utf-8")
    data = yaml.safe_load(text)            # SAFE: never yaml.load
    if data is None:                       # empty file -> empty config
        return {}
    if not isinstance(data, dict):
        raise ValueError(
            f"{path}: top-level YAML must be a mapping, got {type(data).__name__}"
        )
    return data


def load_settings(path: Path) -> Settings:
    raw = load_yaml(path)
    try:
        return Settings.model_validate(raw)
    except ValidationError as exc:
        lines = [f"Invalid configuration in {path}:"]
        for err in exc.errors():
            loc = ".".join(str(p) for p in err["loc"]) or "(root)"
            lines.append(f"  - {loc}: {err['msg']}")
        raise SystemExit("\n".join(lines))

Given this sample.yaml:

host: db.internal
port: 5432
timeout: 30
retries: 3
verbose: true
tags:
  - prod
  - east

load_settings(Path("sample.yaml")) returns a fully typed object:

Loaded & validated: {'host': 'db.internal', 'port': 5432, 'timeout': 30,
 'retries': 3, 'verbose': True, 'tags': ['prod', 'east']}

Handling missing, extra, and wrong keys

The schema decides three failure modes, and each should produce a clear message rather than a stack trace:

Three ways a config file is wrong A table of configuration file errors: a missing required key, an unexpected extra key and a value of the wrong type, with the message each should produce. Three ways a config file is wrong Problem What to say Exit code Missing key name the key and the file line 78 Unknown key name it and suggest the closest match 78 Wrong type show the value and the expected type 78 Malformed YAML the parser line and column 65 Rejecting unknown keys catches typos that a permissive loader would silently ignore for months.
  • Missing required keyhost has no default, so a file without it fails validation with Field required.
  • Extra/typo'd keyextra="forbid" rejects unknown keys. A file with prot: 5432 (typo for port) fails instead of silently dropping the value. This is the single most valuable guard: silent extra-key drops are how users spend an hour wondering why their setting "isn't working."
  • Wrong typeport: "abc" fails because it can't coerce to int. (Note Pydantic will coerce "5432"5432, which is what you want for values that arrive as strings.)

The load_settings error handler above flattens ValidationError.errors() into one line per problem. Feed it a bad.yaml containing a typo:

host: x
prot: 5432

and you get an actionable message, then a non-zero exit:

Invalid configuration in bad.yaml:
  - prot: Extra inputs are not permitted

That is the difference between a CLI a user can debug and one that dumps a 30-line Pydantic traceback at them.

Loading from the right path

Read config from a predictable, documented location, not the current directory by accident. Resolve the path explicitly and handle "file absent" as "use defaults," not as an error:

def load_config(path: Path) -> Settings:
    if not path.is_file():
        return Settings(host="localhost")  # documented fallback
    return load_settings(path)

For where these paths should be — user config under ~/.config/<app>/, project config in the repo — and how to combine a YAML file with environment variables and flags, see the precedence rules in the parent track. The merge there feeds the same safe_load + Pydantic pipeline you built on this page.

Production notes

  • safe_load only, every time. Wrap parsing in one helper (like load_yaml above) so no caller can ever reach for yaml.load. A grep for yaml.load( in CI catches regressions.
  • Audit your plugins. A plugin that parses its own YAML with yaml.load reopens the hole you closed. Hold third-party config readers to the same rule.
  • Catch yaml.YAMLError too. Malformed YAML (bad indentation, duplicate keys) raises YAMLError from safe_load before validation runs. Catch it and report the file and line.
  • Don't trust nesting depth. Deeply nested or huge YAML can be a denial-of-service vector. For files from fully untrusted sources, cap file size before parsing.

Frequently asked questions

Is yaml.safe_load enough on its own?

It is enough for safety, not for correctness. safe_load refuses to construct arbitrary Python objects, which closes the code-execution hole, but it will still return a string where you expected an integer, a null where you expected a list, or a top-level scalar instead of a mapping. Safety and validation are two separate steps and you need both.

What can yaml.load actually do to me?

With the default full loader it can instantiate objects described in the document, which in the classic exploit means a payload that runs a command as your process. The practical risk is not a hostile developer editing their own file — it is a config file fetched from a shared bucket, a chart value from a template, or a file written by a service you do not control. safe_load costs nothing, so there is no case for the alternative.

How should I report an error in a config file?

Name the file, the key and, when the parser gives you one, the line and column. The user is looking at a text file with dozens of keys and the message is their only navigation aid. Then exit with a distinct code — 78 is the conventional value for a configuration problem — so a wrapper script can distinguish a bad config from a failed job.

Should unknown keys in the file be an error?

Yes, in almost every case. A typo like retires: 5 is otherwise ignored forever, and the user is left believing a setting is applied when it is not. Rejecting unknown keys and suggesting the closest known one turns a silent misconfiguration into a five-second fix. The exception is a documented extension point where third-party tools intentionally add their own sections.

Is TOML a better choice than YAML for CLI configuration?

For most tools, yes. TOML has one obvious way to write a value, no significant whitespace, and tomllib in the standard library since Python 3.11 — so there is no dependency and no loader to choose wrongly. YAML earns its place when users already write it (Kubernetes, CI pipelines, Ansible) or when documents are long enough that anchors genuinely help.

Should the CLI write config files as well as read them?

Only if you can do it without destroying the file. A config set command that rewrites the document loses comments, ordering and any keys your parser did not recognise, which users reasonably treat as data loss. Either write to a separate machine-managed file that layers under the hand-edited one, or use a round-tripping parser that preserves formatting. When in doubt, print the change the user should make rather than making it for them.

Does an empty YAML file parse to an empty dict?

No — safe_load returns None for an empty document, which is a classic source of an AttributeError two lines later when something calls .get on it. Normalise immediately with data = yaml.safe_load(text) or {}, and treat a top-level list or scalar as a configuration error with the same message you would give any other wrong shape.