Skip to content
datahacks.dev
JSON

Converting YAML to JSON for Tools That Need It Safely

Convert YAML to JSON with PyYAML and ruamel.yaml, and handle dates, yes and no, octal numbers, non-string keys, anchors, multiple documents and floats.

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

Plenty of tools want JSON and plenty of humans write YAML. The jobs that sit between them are everywhere: a CI configuration fed to a tool that wants JSON, a Kubernetes manifest turned into a document for a validator, an OpenAPI file that a library only reads as JSON. The conversion is a one-liner in Python, and for a simple file it works. For a real file, YAML features that JSON lacks (dates, anchors, comments, several documents in one file, keys that are not strings) change what comes out, and the YAML version of the parser changes what the plain-looking values mean. I ran a document full of these through PyYAML and ruamel.yaml and recorded what each one produced.

The conversion, and the first error

Python
import json, yaml

data = yaml.safe_load(open("config.yaml"))
print(json.dumps(data, indent=2))

safe_load is the function to use. yaml.load without a restricted loader can construct arbitrary Python objects from tags in the file, which means loading an untrusted YAML file can run code. safe_load refuses: a file containing x: !!python/object/apply:os.getcwd [] raised ConstructorError: could not determine a constructor for the tag 'tag:yaml.org,2002:python/object/apply:os.getcwd'. A converter script reading files that come from anywhere should only use the safe functions.

The first failure on my test document came from the second line of that snippet, not from the load:

Text
TypeError: Object of type date is not JSON serializable

The YAML value started: 2026-10-26 is a date in YAML, and PyYAML returned a datetime.date. JSON has no date type, so json.dumps cannot write it. Two ways out:

Python
json.dumps(data, default=str)                              # "2026-10-26", and "2026-10-26 10:00:00" for a timestamp
json.dumps(data, default=lambda o: o.isoformat())          # "2026-10-26" and "2026-10-26T10:00:00"

default=str is quick and writes a space between date and time; isoformat() writes the T form that JSON consumers expect (why JSON has no date type). Pick one on purpose. The more careful position is to ask whether the date should have been converted at all: if the consumer wants the original text, load with a loader that leaves timestamps as strings.

What an unquoted value means depends on the parser

The test document had values that YAML parsers have disagreed about for years. The loaded results:

YAML textPyYAML (1.1)ruamel.yaml (1.2)
country: NOFalse'NO'
enabled: yesTrue'yes'
mode: offFalse'off'
port: 0755493 (octal)755
time: 12:30750 (base 60)'12:30'
version: 1.101.11.1
exp: 1e3'1e3' (a string)1000.0
started: 2026-10-26datedate

Most of these are traps in YAML 1.1, which PyYAML still implements, and which Ansible, many Python config loaders and some Ruby tools use. In 1.1, yes, no, on, off and their capitalizations are booleans, a leading zero means octal, and 12:30 is a base 60 number (sexagesimal). YAML 1.2 removed all of these. The conversion's output depends on which parser you used, and both are "correct". A JSON file with "country": false in place of "NO" is an error in your data, not in the code.

Two values are the same in both: version: 1.10 became the float 1.1 (a number cannot keep a trailing zero), and the date is a date. A version number written as a bare number in YAML loses its zero before the conversion starts. The fix is on the YAML side: quote the version ("1.10"), and quote any country code, since the Norway code NO is the famous case.

If you control the conversion, use the parser that matches the file's intent. For documents written for modern tools (Kubernetes, GitHub Actions, OpenAPI), that is a 1.2 parser. For Ansible files, it is PyYAML. If a file mixes, the safe approach is to quote the ambiguous values at the source, and add a test that loads the file with both and compares the results.

Keys that are not strings

YAML mappings can have any value as a key; JSON objects only have strings. The test file had 1: one, 2026: year and ~: nothing (a null key). After loading, the Python dict had keys of type int and NoneType. json.dumps converts them:

Text
{"1": "one", "2026": "year", "null": "nothing"}

The integer keys became the strings "1" and "2026", and the null key became the text "null". If you convert and parse back, the keys are strings, and a program that looked up integer 1 finds nothing. A YAML key like NO: (the country code as a key) has the same problem as the value: 1.1 turns it into the boolean False, and the JSON key is "false". When you see a key in the JSON that you did not write, look at the YAML for something that parses as a non-string.

Duplicate keys are another difference. a: 1, b: 2, a: 3 loaded in PyYAML as {'a': 3, 'b': 2}, silently keeping the last. ruamel.yaml raised DuplicateKeyError. The YAML specification says keys must be unique, so a duplicate is an error that PyYAML hides. If a duplicate may exist, use ruamel's strict behavior to find it before it changes your data.

Anchors, aliases and merge keys

YAML lets you name a block with an anchor and reuse it:

YAML
defaults: &d
  retries: 3
  timeout: 30
prod:
  <<: *d
  timeout: 60
stage: *d

JSON has no reference mechanism, so the loader expands them. The result was prod: {retries: 3, timeout: 60} and stage: {retries: 3, timeout: 30}: the merge key << copied retries into prod and the explicit timeout: 60 overrode the inherited 30. This is correct and nothing in the JSON remembers that prod and stage came from the same defaults. If you edit the JSON by hand later, you edit three copies. It also means the conversion can make a file much bigger when aliases nest. A "billion laughs" file of aliases of aliases expands exponentially, so do not feed untrusted YAML to a converter without a size limit.

Note that the merge key is a 1.1 feature, and ruamel in 1.2 mode still supports << by default. Check that your two parsers agree on a file that uses it.

Comments, order and multiple documents

Comments are dropped. JSON cannot hold them (JSON has no comments), and safe_load does not return them. If you need to convert and keep them, convert to JSON5 or keep the YAML as the source. Key order is preserved by both loaders (PyYAML builds dicts in file order since Python 3.7).

A YAML file can hold several documents separated by ---. yaml.safe_load of such a file raised ComposerError: expected a single document in the stream. Use safe_load_all, which returned [{'a': 1}, {'b': 2}, [3]] for my test, and decide what the JSON should be: a JSON array of the documents, or one file per document. Kubernetes manifests are routinely multi-document, and an array of the objects is the usual answer.

Special numbers

YAML has .inf, -.inf and .nan. PyYAML loaded .inf as float('inf'), and json.dumps happily wrote Infinity, which is not valid JSON and which strict parsers reject (NaN and Infinity in JSON). Pass allow_nan=False to make json.dumps raise ValueError: Out of range float values are not JSON compliant, then decide how to represent the value (null, a string, or an error).

JSON as input to a YAML parser

JSON is almost a subset of YAML 1.2, and many people use a YAML parser to read JSON. "Almost" has edges I could measure. A JSON number with an exponent but no dot, {"a": 1e3}, was loaded by PyYAML as the string '1e3' and by ruamel as the float 1000.0. A JSON document with a tab after a colon, {"a":<tab>1}, failed in PyYAML with ScannerError and loaded in ruamel. NaN was a string in both. So a YAML 1.1 parser is not a JSON parser; when the input is JSON, parse it with a JSON parser.

A script that checks the result

Python
import json, sys, yaml
from datetime import date, datetime

def default(o):
    if isinstance(o, (date, datetime)):
        return o.isoformat()
    raise TypeError(f"cannot serialize {type(o).__name__}")

docs = list(yaml.safe_load_all(open(sys.argv[1])))
bad_keys = [k for d in docs if isinstance(d, dict) for k in d if not isinstance(k, str)]
if bad_keys:
    print("non-string keys:", bad_keys, file=sys.stderr)
out = docs[0] if len(docs) == 1 else docs
print(json.dumps(out, indent=2, default=default, allow_nan=False, ensure_ascii=False))

It uses the safe loader, supports multi-document files, reports non-string keys to standard error, writes ISO dates, refuses Infinity, and keeps non-ASCII text readable. Add a check that lists the values the loader changed type for (booleans where the YAML text was not true or false) if your files are not under your control.

When the YAML should stay YAML

The consumer reads YAML. Many tools that say JSON also accept YAML. Kubernetes tools take both, for instance, and converting adds a step that can only lose information.

The file is edited by people. Comments, anchors and readable layout are the reason it is YAML. Generate the JSON as a build artifact, and never edit the generated file.

You cannot name the parser version. If the same file is read by a 1.1 and a 1.2 parser, quote the ambiguous values until both agree.

The type rules are in the YAML 1.2.2 specification and the differences from 1.1 are summarized in its appendix on revisions. PyYAML's safe loading is described in its documentation. The reverse conversion is in converting JSON to YAML on the command line. Check the JSON you produced with the JSON formatter.

Also inYAML
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.