How big is JSON really, after compression?
Measure your machineNeeds Python with fastavro, zstandard
Problem
A self-describing format such as JSON repeats every field name in every record and writes numbers as decimal text; a schema-based binary format such as Avro writes neither the names nor the text, so its raw size is several times smaller. A general-purpose compressor removes much of that difference, because repeated field names are exactly what it is good at. What remains after compression is the information the data really carries, and that is the same for both.
Reproduce "JSON is 7× bigger than Avro raw, but only ~1.3–2× after compression". The script encodes 1,000 parcel-scan records both ways, compresses both with zstd level 9, then repeats with sequential parcel IDs.
The question. How much bigger is JSON than Avro before compression, and after zstd?
Predict
A large gap in raw size and a much smaller gap after ZSTD. Random IDs and times reduce the compression of both, so your numbers will differ from this mechanism; the direction should hold.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
how-big-is-json-really-after.py # /// script # requires-python = ">=3.10" # dependencies = ["fastavro", "zstandard"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """How big is JSON really, after compression? The same 1,000 parcel-scan records as JSON lines and as Avro (no field names, binary numbers), raw and after Zstd level 9; then with sequential parcel ids. """ import argparse, io, json, random import zstandard as zstd from fastavro import schemaless_writer, parse_schema p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--records", type=int, default=1000, help="Records.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() schema = parse_schema({"type": "record", "name": "Scan", "fields": [ {"name": "parcel_id", "type": "long"}, {"name": "scanner_id", "type": "int"}, {"name": "event_time", "type": "long"}, {"name": "status", "type": "string"}, {"name": "weight_grams", "type": "int"}, {"name": "depot", "type": "string"}]}) z = lambda b: len(zstd.ZstdCompressor(level=9).compress(b)) rows = [] for label, seq in (("random parcel ids", False), ("sequential parcel ids", True)): random.seed(1) recs = [{"parcel_id": (10**8 + i) if seq else random.randrange(10**9), "scanner_id": random.randrange(500), "event_time": 1_700_000_000_000_000 + i * 1000, "status": random.choice(["LOADED", "SCANNED", "DELIVERED"]), "weight_grams": random.randrange(100, 30000), "depot": random.choice(["PUNE-01", "MUMBAI-02", "DELHI-01"])} for i in range(args.records)] js = "\n".join(json.dumps(r) for r in recs).encode() buf = io.BytesIO() for r in recs: schemaless_writer(buf, schema, r) av = buf.getvalue() rows.append({"ids": label, "json_raw": len(js), "avro_raw": len(av), "raw_ratio": len(js) / len(av), "json_zstd": z(js), "avro_zstd": z(av), "zstd_ratio": z(js) / z(av)}) r = rows[-1] print(f"{label:<22} raw: JSON {r['json_raw']:,} B, Avro {r['avro_raw']:,} B ({r['raw_ratio']:.1f}x) " f"zstd: JSON {r['json_zstd']:,} B, Avro {r['avro_zstd']:,} B ({r['zstd_ratio']:.1f}x)") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"sizes": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run how-big-is-json-really-after.py uv run how-big-is-json-really-after.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --records1000 | Records. |
Our run
1,000 records, two encodings, raw and compressed (bytes)
| IDs | JSON raw | Avro raw | Raw ratio | JSON zstd | Avro zstd | zstd ratio |
|---|---|---|---|---|---|---|
| random parcel ids | 141K | 34.8K | 4.04× | 15.8K | 13.5K | 1.17× |
| sequential parcel ids | 141K | 34K | 4.14× | 11K | 11.9K | 0.929× |
random parcel ids raw: JSON 140,659 B, Avro 34,817 B (4.0x) zstd: JSON 15,833 B, Avro 13,475 B (1.2x)
sequential parcel ids raw: JSON 140,810 B, Avro 33,982 B (4.1x) zstd: JSON 11,022 B, Avro 11,863 B (0.9x)What we found
Raw, JSON was 4.04× the size of Avro, less than the quoted 7×: these records have six short fields, so the field names are a smaller share than in wider records. After zstd the gap shrank to 1.17×. With sequential IDs it closed entirely, to 0.929×: the compressor found the sequence in JSON's decimal text slightly more easily than in Avro's variable-length integers.
Read it
The direction held: the raw gap is large and the compressed gap small. The raw ratio depends on how much of each record is field names, and the compressed ratio on what the data itself contains. Comparing formats by raw size overstates the case for binary formats on the wire and on disk once compression is on.
What the binary format still buys after compression is not size but parse cost and a schema: decoding Avro is far cheaper than parsing JSON, and the schema is a contract.
Go further
Try making parcel_id sequential to see compression help more.