json1-10 Exercise 1: What YAML Loaders Do to Unquoted Values, and Is YAML Really a JSON Superset? ================================================================================================== Run with Python 3.14.0 (PyYAML 6.0.3 with libyaml, ruamel.yaml 0.19.1, tomllib from the standard library, tomli_w 1.2.0, json5 0.17.3, xmltodict 1.0.4, protobuf 7.36.2 with the upb backend, grpcio-tools 1.84.0) and Node 24.18.0 (yaml 2.9.1, js-yaml 5.4.3, smol-toml 1.9.1, json5 2.2.3, jsonc-parser 3.3.1, fast-xml-parser 5.11.2) on Windows 11, installed in a scratch folder. --- traps.yaml --- norway: NO sweden: SE lower_no: no yes_word: yes on_word: on quoted_no: "NO" version: 1.10 octal_old: 014 octal_new: 0o14 hex: 0x1F sexagesimal: 22:22 thousands: 1_000 exp: 1e3 date: 2026-10-12 datetime: 2026-10-12 08:30:00 tilde: ~ empty: inf: .inf nan: .nan zip: 01234 colon_str: "a: b" --- yt.py --- import yaml, json from ruamel.yaml import YAML def show(v): return type(v).__name__ + ":" + repr(v) src = open("traps.yaml", encoding="utf-8").read() a = yaml.safe_load(src) b = YAML(typ="safe").load(src) print("%-12s | %-28s | %-28s" % ("key", "PyYAML 6.0.3 safe_load", "ruamel.yaml 0.19.1 (safe)")) for k in a: print("%-12s | %-28s | %-28s" % (k, show(a[k])[:28], show(b.get(k, ""))[:28])) --- yt.mjs --- import fs from "node:fs"; import YAML from "yaml"; import * as jsyaml from "js-yaml"; const src = fs.readFileSync("traps.yaml", "utf8"); const s = (v) => (v === null ? "null" : typeof v === "object" ? (v instanceof Date ? "Date:" + v.toISOString() : "object:" + JSON.stringify(v)) : typeof v + ":" + (Number.isNaN(v) ? "NaN" : String(v))); const a = YAML.parse(src), b = YAML.parse(src, { version: "1.1" }); let c; try { c = jsyaml.load(src); } catch (e) { c = null; console.log("js-yaml error:", e.message.split("\n")[0]); } console.log("%s | %s | %s | %s", "key".padEnd(12), "yaml 2.9.1 (1.2)".padEnd(26), "yaml {version:'1.1'}".padEnd(26), "js-yaml 5.4.3".padEnd(26)); for (const k of Object.keys(a)) console.log("%s | %s | %s | %s", k.padEnd(12), s(a[k]).slice(0, 26).padEnd(26), s(b[k]).slice(0, 26).padEnd(26), c ? s(c[k]).slice(0, 26).padEnd(26) : ""); Output of python yt.py (PyYAML safe_load against ruamel.yaml safe): key | PyYAML 6.0.3 safe_load | ruamel.yaml 0.19.1 (safe) norway | bool:False | str:'NO' sweden | str:'SE' | str:'SE' lower_no | bool:False | str:'no' yes_word | bool:True | str:'yes' on_word | bool:True | str:'on' quoted_no | str:'NO' | str:'NO' version | float:1.1 | float:1.1 octal_old | int:12 | int:14 octal_new | str:'0o14' | int:12 hex | int:31 | int:31 sexagesimal | int:1342 | str:'22:22' thousands | int:1000 | int:1000 exp | str:'1e3' | float:1000.0 date | date:datetime.date(2026, 10, | date:datetime.date(2026, 10, datetime | datetime:datetime.datetime(2 | datetime:datetime.datetime(2 tilde | NoneType:None | NoneType:None empty | NoneType:None | NoneType:None inf | float:inf | float:inf nan | float:nan | float:nan zip | int:668 | int:1234 colon_str | str:'a: b' | str:'a: b' Output of node yt.mjs (yaml 2.9.1 default, yaml with version 1.1, js-yaml): key | yaml 2.9.1 (1.2) | yaml {version:'1.1'} | js-yaml 5.4.3 norway | string:NO | boolean:false | string:NO sweden | string:SE | string:SE | string:SE lower_no | string:no | boolean:false | string:no yes_word | string:yes | boolean:true | string:yes on_word | string:on | boolean:true | string:on quoted_no | string:NO | string:NO | string:NO version | number:1.1 | number:1.1 | number:1.1 octal_old | number:14 | number:12 | number:14 octal_new | number:12 | string:0o14 | number:12 hex | number:31 | number:31 | number:31 sexagesimal | string:22:22 | number:1342 | string:22:22 thousands | string:1_000 | number:1000 | string:1_000 exp | number:1000 | number:1000 | number:1000 date | string:2026-10-12 | Date:2026-10-12T00:00:00.0 | string:2026-10-12 datetime | string:2026-10-12 08:30:00 | Date:2026-10-12T08:30:00.0 | string:2026-10-12 08:30:00 tilde | null | null | null empty | null | null | null inf | number:Infinity | number:Infinity | number:Infinity nan | number:NaN | number:NaN | number:NaN zip | number:1234 | number:668 | number:1234 colon_str | string:a: b | string:a: b | string:a: b --- sup.py (ten JSON texts through json.loads, PyYAML and ruamel; escape characters are built with chr() so none is typed by hand) --- import json, yaml from ruamel.yaml import YAML BS = chr(92) cases = { "compact object": '{"a":1,"b":[1,2]}', "tab between tokens": '{"a":' + chr(9) + '1}', "escaped slash": '{"a":"x' + BS + '/y"}', "escape u00e9": '{"a":"' + BS + 'u00e9"}', "surrogate pair escape": '{"a":"' + BS + 'ud83d' + BS + 'ude00"}', "duplicate key": '{"a":1,"a":2}', "number 1E400": '{"a":1E400}', "exponent no dot 1e3": '{"a":1e3}', "big int": '{"a":12345678901234567890}', "key without quotes (not JSON)": '{a: 1}', } def tryf(f, s): try: return repr(f(s))[:44] except Exception as e: return "ERROR " + type(e).__name__ print("%-30s | %-26s | %-46s | %-46s" % ("input", "json.loads", "PyYAML safe_load", "ruamel safe")) for k, s in cases.items(): print("%-30s | %-26s | %-46s | %-46s" % (k, tryf(json.loads, s)[:26], tryf(yaml.safe_load, s), tryf(YAML(typ="safe").load, s))) Output: input | json.loads | PyYAML safe_load | ruamel safe compact object | {'a': 1, 'b': [1, 2]} | {'a': 1, 'b': [1, 2]} | {'a': 1, 'b': [1, 2]} tab between tokens | {'a': 1} | ERROR ScannerError | {'a': 1} escaped slash | {'a': 'x/y'} | {'a': 'x/y'} | {'a': 'x/y'} escape u00e9 | {'a': 'é'} | {'a': 'é'} | {'a': 'é'} surrogate pair escape | {'a': '😀'} | {'a': '\ud83d\ude00'} | {'a': '\ud83d\ude00'} duplicate key | {'a': 2} | {'a': 2} | ERROR DuplicateKeyError number 1E400 | {'a': inf} | {'a': '1E400'} | {'a': inf} exponent no dot 1e3 | {'a': 1000.0} | {'a': '1e3'} | {'a': 1000.0} big int | {'a': 12345678901234567890 | {'a': 12345678901234567890} | {'a': 12345678901234567890} key without quotes (not JSON) | ERROR JSONDecodeError | {'a': 1} | {'a': 1} --- sup.mjs --- import YAML from "yaml"; import * as jsyaml from "js-yaml"; const BS = String.fromCharCode(92); const cases = { "compact object": '{"a":1,"b":[1,2]}', "tab between tokens": '{"a":\t1}', "escaped slash": '{"a":"x' + BS + '/y"}', "escape u00e9": '{"a":"' + BS + 'u00e9"}', "surrogate pair escape": '{"a":"' + BS + 'ud83d' + BS + 'ude00"}', "duplicate key": '{"a":1,"a":2}', "number 1E400": '{"a":1E400}', "exponent no dot 1e3": '{"a":1e3}', "big int": '{"a":12345678901234567890}', }; const t = (f, s) => { try { return JSON.stringify(f(s)); } catch (e) { return "ERROR " + e.name + ": " + e.message.split("\n")[0].slice(0, 40); } }; console.log("input".padEnd(24), "|", "JSON.parse".padEnd(30), "|", "yaml 2.9.1".padEnd(48), "|", "js-yaml 5.4.3"); for (const [k, s] of Object.entries(cases)) console.log(k.padEnd(24), "|", t(JSON.parse, s).slice(0, 30).padEnd(30), "|", t((x) => YAML.parse(x), s).slice(0, 48).padEnd(48), "|", t((x) => jsyaml.load(x), s).slice(0, 60)); Output (note: JSON.stringify prints Infinity as null, so the 1E400 cells show null for JSON.parse and for yaml; both really returned Infinity): input | JSON.parse | yaml 2.9.1 | js-yaml 5.4.3 compact object | {"a":1,"b":[1,2]} | {"a":1,"b":[1,2]} | {"a":1,"b":[1,2]} tab between tokens | {"a":1} | {"a":1} | {"a":1} escaped slash | {"a":"x/y"} | {"a":"x/y"} | {"a":"x/y"} escape u00e9 | {"a":"é"} | {"a":"é"} | {"a":"é"} surrogate pair escape | {"a":"😀"} | {"a":"😀"} | {"a":"😀"} duplicate key | {"a":2} | ERROR YAMLParseError: Map keys must be unique at | ERROR YAMLException: duplicated mapping key (1:9) number 1E400 | {"a":null} | {"a":null} | {"a":"1E400"} exponent no dot 1e3 | {"a":1000} | {"a":1000} | {"a":1000} big int | {"a":12345678901234567000} | {"a":12345678901234567000} | {"a":12345678901234567000} WHY THIS WORKS AS AN ANSWER --------------------------- The same file goes to every loader, so the differences are the loaders', not the input's. The table shows the Norway problem (NO is false in PyYAML and in yaml with version 1.1, a string otherwise), 1.10 read as 1.1 by all four loaders, 01234 read as 668 or 1234, 22:22 as 1342 or a string, and 1e3 as a string in PyYAML. The quoted "NO" is a string in all of them. The superset test shows where the claim breaks for a 1.1 loader: PyYAML rejects a tab between tokens, turns a surrogate-pair escape into two lone surrogate characters (ruamel does the same), reads 1e3 and 1E400 as strings, and accepts a duplicate key silently, as json.loads does, while ruamel, yaml and js-yaml reject it. I also checked that 'colon_str: a: b' is a parse error (PyYAML: mapping values are not allowed here; yaml: nested mappings are not allowed in compact mappings), which is why the file has it quoted. Not tested: unsafe loaders, anchors and merge keys, other YAML libraries, other versions.