Skip to content
datahacks.dev
YAML

Loading YAML in Python With PyYAML safe_load Explained

What yaml.safe_load returns, how to open files correctly, why the C loader is six times faster and the type surprises that come with PyYAML.

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

yaml.safe_load is the function most Python code uses to read configuration, and most code uses it without knowing exactly what it does. It is a good default. It is also a function with sharp edges: an empty file returns None, a stream with two documents raises, and the types of your values come from rules written in 2005. This post explains what it returns, how to call it, how to make it faster and what to watch for.

The call

Python
import yaml

with open("config.yaml", encoding="utf-8") as f:
    cfg = yaml.safe_load(f)

safe_load accepts either a string or an open file (text or binary). Open the file yourself, with an explicit encoding, so you decide what happens to a byte order mark or a different encoding. Passing a path string does not work: yaml.safe_load("config.yaml") parses the text config.yaml as a document and returns the string 'config.yaml', a bug that looks like success.

The result for a typical document is nested dictionaries and lists with scalars at the leaves. The scalar types are str, int, float, bool, None, and datetime.date or datetime.datetime for timestamps. Nothing else: safe_load uses the SafeLoader, which knows only the standard YAML tags and refuses language-specific ones. A document that asks for a Python object is rejected:

Text
safe_load: ConstructorError - could not determine a constructor for the tag 'tag:yaml.org,2002:python/object/apply:built...

That refusal is the point of the function. The sibling yaml.load with an unsafe loader runs the code the tag names (why yaml load is dangerous).

Edge cases to handle

An empty file (or one with only comments) returns None, not {}. Code that does cfg["key"] fails with a TypeError. Normalize: cfg = yaml.safe_load(f) or {}.

A file with several documents raises ComposerError: expected a single document in the stream. Use yaml.safe_load_all, which yields each document (loading multiple documents from one YAML file).

A top-level list or scalar is valid YAML. A config loader that assumes a mapping should check isinstance(cfg, dict) and fail with a clear message.

Syntax errors raise yaml.YAMLError subclasses with a problem_mark that gives the line and column, which you can turn into a friendly message (fixing YAML errors that make no sense has a wrapper).

Duplicate keys are accepted and the last value wins (YAML duplicate keys and the silent overwrite).

The C loader is faster

PyYAML ships two implementations of the loader. The default SafeLoader is pure Python. If the libyaml C library was available when PyYAML was built, CSafeLoader uses it. On the generated 1.4 MB document:

Text
SafeLoader (pure Python)     4.16s
CSafeLoader (libyaml)        0.67s
ruamel safe                  5.49s
ruamel round-trip            7.52s

CSafeLoader was six times faster than the default loader and eight times faster than ruamel.yaml's round-trip mode. Use it when you parse large files:

Python
Loader = getattr(yaml, "CSafeLoader", yaml.SafeLoader)
cfg = yaml.load(f, Loader=Loader)

The getattr falls back to the pure Python loader on systems without libyaml. Note that yaml.safe_load itself always uses the pure Python SafeLoader; you need yaml.load(..., Loader=CSafeLoader) to get the fast one, which is safe because the loader class is the safe one. Error messages differ slightly between the two: the C loader uses libyaml's wording (did not find expected key) while the Python loader says expected <block end> for the same problem (YAML error: did not find expected key).

For configuration files of a few hundred lines none of this matters. It matters for data files, and for programs that parse many files at start-up. If you load megabytes of YAML regularly, consider whether another format would be better (YAML versus JSON for configuration measured JSON at 55 times faster on bulk data).

The types you get

PyYAML implements YAML 1.1. The practical consequences:

Python
yaml.safe_load("a: NO\nb: 010\nc: 12:30\nd: 3.10\ne: 2026-01-05\n")
Text
{'a': False, 'b': 8, 'c': 750, 'd': 3.1, 'e': datetime.date(2026, 1, 5)}

Every one of these is a trap if you meant a string: a country code, a file mode or identifier, a time of day, a version, a date you wanted as text. The remedy is quoting in the file, and a validation step in the code (the Norway problem and YAML boolean surprises, why a number turns into something else in YAML). If dates give trouble when you serialize the result to JSON (TypeError: Object of type date is not JSON serializable), either quote them in the file or pass default=str to json.dumps.

If you need YAML 1.2 behavior from Python, use ruamel.yaml: its safe mode loaded NO as the string and 12:30 as a string in the tests in this cluster. It is slower and has a different API (preserving comments and order with ruamel yaml).

A reliable loader function

Putting the pieces together, a small function used everywhere in a project:

Python
import yaml

def load_yaml(path):
    Loader = getattr(yaml, "CSafeLoader", yaml.SafeLoader)
    with open(path, encoding="utf-8") as f:
        try:
            data = yaml.load(f, Loader=Loader)
        except yaml.YAMLError as e:
            mark = getattr(e, "problem_mark", None)
            where = f" at line {mark.line + 1}, column {mark.column + 1}" if mark else ""
            raise ValueError(f"{path}: invalid YAML{where}: {getattr(e, 'problem', e)}") from e
    return {} if data is None else data

It uses the fast loader when available, reports the position of errors in one line, and turns an empty file into an empty mapping. Add validation on top (validating YAML configuration with pydantic), and every configuration read in the project passes through one function that you can test.

Testing the loader

A loader function used by every configuration read deserves a few tests, and they are cheap. Test that an empty file gives an empty mapping. Test that a file with a syntax error raises your error type, with the file name and the line. Test that a file with a tag you do not support is rejected and does not execute anything. Test one ambiguous value through the whole path, for example a string "NO" in the file arriving as the string, so a future change to quoting rules or loaders is noticed.

Keep a few fixture files next to the tests: a valid minimal config, a config with every optional key, a config with the typical mistakes. The fixtures document what the loader accepts. When a production incident teaches you a new failure, add the smallest file that reproduces it, with a comment. The loader and its fixtures become the single place where the project's rules about YAML live, and the rest of the code can assume that what it receives is a dictionary of the shape it expects.

The cluster hub is reading and writing YAML in Python safely. The next steps are writing YAML from Python without ugly output and validating YAML configuration with pydantic. PyYAML is documented at pyyaml.org, and the safe loader's behavior in the source repository. Compare a document's types in the YAML to JSON tool.

When safe_load is not the right call

You must keep comments and order. safe_load discards comments. Use ruamel.yaml in round-trip mode for files that people also edit.

You need YAML 1.2. PyYAML implements 1.1. Use ruamel.yaml, or quote ambiguous values.

The file is very large. Use the C loader, or choose a format meant for data.

You need custom types. Register a constructor on a subclass of SafeLoader instead of switching to an unsafe loader. That way you control exactly which tags can construct what.

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.