json1-10 Exercise 3: Round Trips, Size and Speed on 100,000 Records, and Protocol Buffers ========================================================================================== 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. Timings are single runs on one machine; compare the ratios. The 100,000 records are the first 100,000 lines of the Chapter 9 data file (r100k.jsonl: head -n 100000 data.jsonl). --- doc.json (the nine-field configuration document) --- {"name":"shop","version":"1.10","debug":false,"port":8080,"ratio":0.25,"created":"2026-10-12","tags":["web"],"owner":{"name":"Ada","email":null},"servers":[{"host":"a","port":1},{"host":"b","port":2}]} --- rt.py (write each format, read it back, list every difference) --- import json, yaml, tomllib, tomli_w, xmltodict, csv, io doc = json.load(open("doc.json", encoding="utf-8")) def diff(a, b, path=""): out = [] if isinstance(a, dict) and isinstance(b, dict): for k in sorted(set(a) | set(b)): if k not in a: out.append("%s/%s only after" % (path, k)) elif k not in b: out.append("%s/%s lost" % (path, k)) else: out += diff(a[k], b[k], path + "/" + k) elif isinstance(a, list) and isinstance(b, list) and len(a) == len(b): for i, (x, y) in enumerate(zip(a, b)): out += diff(x, y, "%s/%d" % (path, i)) elif a != b or type(a) != type(b): out.append("%s: %r (%s) -> %r (%s)" % (path, a, type(a).__name__, b, type(b).__name__)) return out def report(name, f): try: text, back = f() d = diff(doc, back) print("== %s: %s" % (name, "round trip identical" if not d else "%d difference(s)" % len(d))) for x in d: print(" ", x) return text except Exception as e: print("== %s: FAILED %s: %s" % (name, type(e).__name__, str(e)[:100])) def via_yaml(): t = yaml.safe_dump(doc, sort_keys=False); return t, yaml.safe_load(t) def via_toml(): t = tomli_w.dumps(doc); return t, tomllib.loads(t) def via_xml(): t = xmltodict.unparse({"config": doc}, pretty=True); return t, xmltodict.parse(t)["config"] t = report("YAML (PyYAML dump then load)", via_yaml); print(t) report("TOML as is (has a null)", via_toml) nonull = json.loads(json.dumps(doc)); nonull["owner"].pop("email") doc_saved = doc; doc = nonull t = report("TOML after removing the null", via_toml); print(t) doc = doc_saved t = report("XML (xmltodict)", via_xml); print(t) Output: == YAML (PyYAML dump then load): round trip identical name: shop version: '1.10' debug: false port: 8080 ratio: 0.25 created: '2026-10-12' tags: - web owner: name: Ada email: null servers: - host: a port: 1 - host: b port: 2 == TOML as is (has a null): FAILED TypeError: Object of type 'NoneType' is not TOML serializable == TOML after removing the null: round trip identical name = "shop" version = "1.10" debug = false port = 8080 ratio = 0.25 created = "2026-10-12" tags = [ "web", ] servers = [ { host = "a", port = 1 }, { host = "b", port = 2 }, ] [owner] name = "Ada" == XML (xmltodict): 6 difference(s) /debug: False (bool) -> 'false' (str) /port: 8080 (int) -> '8080' (str) /ratio: 0.25 (float) -> '0.25' (str) /servers/0/port: 1 (int) -> '1' (str) /servers/1/port: 2 (int) -> '2' (str) /tags: ['web'] (list) -> 'web' (str) shop 1.10 false 8080 0.25 2026-10-12 web Ada a 1 b 2 --- rec.proto (compiled with: python -m grpc_tools.protoc -I. --python_out=. rec.proto) --- syntax = "proto3"; message Record { int64 id = 1; string name = 2; string status = 3; double amount = 4; repeated string tags = 5; string ts = 6; } --- bm.py (eight rows, each round trip checked against the original records) --- import json, yaml, tomllib, tomli_w, xmltodict, csv, io, gzip, time, struct from xml.etree import ElementTree as ET import rec_pb2 recs = [json.loads(l) for l in open("r100k.jsonl", encoding="utf-8")] N = len(recs); print("records:", N) def T(f): t = time.perf_counter(); r = f(); return r, time.perf_counter() - t rows = [] def add(name, enc, dec, check=None): b, te = T(enc); g = len(gzip.compress(b, 6)); back, td = T(lambda: dec(b)) rows.append((name, len(b), g, te, td, check(back) if check else "-")) same = lambda back: back == recs add("JSON array (json)", lambda: json.dumps(recs, ensure_ascii=False, separators=(",", ":")).encode(), lambda b: json.loads(b), same) add("JSON Lines (json)", lambda: ("\n".join(json.dumps(r, ensure_ascii=False, separators=(",", ":")) for r in recs) + "\n").encode(), lambda b: [json.loads(l) for l in b.decode().split("\n") if l], same) add("YAML (PyYAML SafeDumper)", lambda: yaml.dump(recs, Dumper=yaml.SafeDumper, allow_unicode=True, sort_keys=False).encode(), lambda b: yaml.load(b, Loader=yaml.SafeLoader), same) add("YAML (PyYAML C loader/dumper)", lambda: yaml.dump(recs, Dumper=yaml.CSafeDumper, allow_unicode=True, sort_keys=False).encode(), lambda b: yaml.load(b, Loader=yaml.CSafeLoader), same) add("TOML (tomli_w / tomllib)", lambda: tomli_w.dumps({"records": recs}).encode(), lambda b: tomllib.loads(b.decode())["records"], same) def to_csv(): o = io.StringIO(newline=""); w = csv.writer(o, lineterminator="\n"); w.writerow(["id", "name", "status", "amount", "tags", "ts"]) for r in recs: w.writerow([r["id"], r["name"], r["status"], r["amount"], json.dumps(r["tags"], separators=(",", ":")), r["ts"]]) return o.getvalue().encode() def from_csv(b): return [{"id": int(d["id"]), "name": d["name"], "status": d["status"], "amount": float(d["amount"]), "tags": json.loads(d["tags"]), "ts": d["ts"]} for d in csv.DictReader(io.StringIO(b.decode(), newline=""))] add("CSV (tags as a JSON cell)", to_csv, from_csv, same) add("XML (xmltodict)", lambda: xmltodict.unparse({"records": {"record": recs}}, pretty=False).encode(), lambda b: xmltodict.parse(b), lambda back: "n/a (all strings)") add("XML parse only (ElementTree)", lambda: xmltodict.unparse({"records": {"record": recs}}, pretty=False).encode(), lambda b: ET.fromstring(b), lambda back: "%d elements" % len(back)) def pb_enc(): out = bytearray() for r in recs: m = rec_pb2.Record(id=r["id"], name=r["name"], status=r["status"], amount=r["amount"], tags=r["tags"], ts=r["ts"]).SerializeToString() out += struct.pack(" the int64 is written as a string") # unknown field tolerance: a record with an extra field, read by the old schema Output: JSON line : {"id":0,"name":"Noor628","status":"shipped","amount":134.3,"tags":["a"],"ts":"2026-06-17T14:57:00Z"} (100 bytes) protobuf : 12 07 4e 6f 6f 72 36 32 38 1a 07 73 68 69 70 70 65 64 21 9a 99 99 99 99 c9 60 40 2a 01 61 32 14 32 30 32 36 2d 30 36 2d 31 37 54 31 34 3a 35 37 3a 30 30 5a (52 bytes) protobuf's own JSON mapping: {"name": "Noor628", "status": "shipped", "amount": 134.3, "tags": ["a"], "ts": "2026-06-17T14:57:00Z"} id 2**53+1 in protobuf JSON mapping: {"id": "9007199254740993"} -> the int64 is written as a string WHY THIS WORKS AS AN ANSWER --------------------------- The round trip compares the document before and after, field by field, with types: YAML is identical (the dumper quotes '1.10' and the date string), TOML fails on the null and is identical once it is removed, and XML shows 6 differences. The benchmark checks the decoded records equal the originals for every format except XML through xmltodict (all strings), so the sizes and times are for equal data. After gzip every format is between 1.68 MB (CSV) and 2.00 MB (XML), within about 11% of JSON's 1.88 MB; YAML reads about 230 times slower than JSON in the pure-Python loader and about 115 times slower with libyaml. The CSV and Protocol Buffers rows include my own conversion code, and the XML read time mixes xmltodict (builds Python objects) and ElementTree (builds an element tree) as labelled. The protobuf output shows 52 bytes against 100, the first record's zero id missing from both the bytes and the JSON mapping, and an id of 2**53+1 written as a string. Not tested: schema evolution, other languages' protobuf libraries, and other machines or library versions.