diff --git a/docs/blog.css b/docs/blog.css index 651bc65..fdccdef 100644 --- a/docs/blog.css +++ b/docs/blog.css @@ -172,6 +172,22 @@ article strong { color: #fff; } +/* Diagrams are PNGs with their own dark card, so they import cleanly into Medium. */ +article figure { + margin: 2rem 0; +} +article figure img { + display: block; + width: 100%; + height: auto; + border-radius: 12px; +} +article figcaption { + margin-top: 0.6rem; + font-size: 0.82rem; + color: var(--muted); +} + /* The one number the piece exists to show. */ .lede { font-size: 1.15rem; diff --git a/docs/blog/avro-vs-parquet-vs-jsonl.html b/docs/blog/avro-vs-parquet-vs-jsonl.html new file mode 100644 index 0000000..a5c62ad --- /dev/null +++ b/docs/blog/avro-vs-parquet-vs-jsonl.html @@ -0,0 +1,571 @@ + + + + + + Avro vs Parquet vs JSON Lines: one dataset, three very different files + + + + + + + + +
+
+
+

+ Avro vs Parquet vs JSON Lines: one dataset, three very + different files +

+ + +

+ Correct at time of publishing. For up-to-date + performance and feature information, see + rugo.dev. +

+
+ +

+ The same 4,630 rows take 1.09 MB as JSON + Lines, 113 KB as Avro and + 93 KB as Parquet. Size is the least + interesting difference. What matters is how much of each + file a reader can skip, and that decides + where each format belongs. +

+ +

+ Ask which format to use and you'll get a one-line answer: + Parquet for analytics, Avro for streaming, JSON Lines for + logs. All three are true, and none of them helps until you + know why. The reasons are in the bytes, so we'll open the + same dataset in all three formats and look. +

+ +

+ The data is 4,630 orbital launch attempts, from Sputnik + onwards: company, rocket, price, launch time, outcome. It's + small enough to inspect by eye, and every file here is in + the + rugo.dev repository + if you want to follow along. +

+ +
+ Bar chart of the same 4,630 launches: JSON Lines 1.09 MB, Avro 113 KB (9.7 times smaller), Parquet 93 KB (11.7 times smaller). +
+ +

The one idea: rows or columns

+ +

+ A table is two-dimensional. A file is one long run of bytes. + Every format has to pick an order for the cells, and there + are only two sensible choices: write one whole record after + another, or write one whole column after another. +

+ +
+ A table of three records and three fields, shown two ways. Row-oriented storage writes A1 B1 C1, then A2 B2 C2, then A3 B3 C3. Column-oriented storage writes A1 A2 A3, then B1 B2 B3, then C1 C2 C3. +
+ +

+ JSON Lines and Avro are row-oriented. Parquet is + column-oriented. Nearly everything else in this post follows + from that choice, and from what each format adds to make + its choice workable. +

+ +

JSON Lines: one record per line

+ +

+ JSON Lines (also called NDJSON) is as simple as a format + gets: one JSON object per line, separated by newlines. +

+ +
+ Three lines of JSON Lines from the dataset, with field names highlighted, and three notes: every line carries its own keys, a new record is a new line, and everything is text. +
+ +

+ Its strengths come from that simplicity. Any language can + write it. You can open it in a text editor, search it with + grep, and add to it with nothing more than a + file write. A half-written file is still a valid file up to its + last complete line. There's nothing to install and nothing + to configure, which is why it's the default for logs and + API exports. +

+ +

+ The costs come from the same place. Every line repeats + every key, so the field names are written 4,630 times. + Numbers and timestamps are text that has to be parsed on + every read. There is no schema, so the type of + Price is whatever the first rows suggest, and + a reader has to find the start of each record by scanning + for newlines. To get at one field you scan the whole file. +

+ +

Avro: a header, then blocks

+ +

+ Avro keeps the row orientation and makes everything about it + explicit. The file opens with a header holding the schema as + JSON, then holds the records in blocks. +

+ +
+ An Avro file is a header followed by blocks. The header holds the magic bytes, the schema, the codec and a 16-byte sync marker. Each block holds a record count, a byte size, the compressed records and the sync marker. +
+ +

+ The schema is the point of Avro. It travels with the data, + so the file explains itself. It supports rich types: + timestamps, decimals, enums, nested records, arrays and + maps. Records are packed back to back with no keys and + each block is compressed, so this file comes out nine times + smaller than the JSON Lines one. And because + readers and writers each declare a schema, they can differ: + add a field with a default value and old files still read, + which is why Avro is so common between services that are + deployed on different days. +

+ +

+ Blocks are the other half. Each one is compressed on its + own and ends with the sync marker, so a reader can drop in + at any block boundary. That makes an Avro file splittable + across workers, and cheap to append to: you add another + block. +

+ +

+ What Avro doesn't keep is anything about the contents. A + block records how many records it holds and how many bytes + long it is, and nothing else. There are no per-column + statistics to tell a reader a block can't match, so a read + inflates every block and walks every record, skipping over + the fields it doesn't want. +

+ +

Parquet: column chunks and a footer

+ +

+ Parquet turns the layout on its side. Rows are grouped into + row groups, and inside each row group every column is stored + as its own contiguous chunk. The file ends with a footer + that says where everything is. +

+ +
+ A Parquet file is the PAR1 marker, row groups, a footer, then the footer length and PAR1 again. Row group 1 holds eight column chunks of different sizes. The footer holds the schema and, for every chunk, its offset, size, minimum, maximum and null count. +
+ +

+ Two things fall out of that. The first is compression. Put + a thousand company names next to each other and they + compress far better than the same names scattered between + prices and timestamps, and a column of repeated values + shrinks further still through dictionary encoding. Each + column gets the encoding that suits it. That's how this + file ends up the smallest of the three. +

+ +

+ The second is skipping. The footer records the minimum and + maximum of every column chunk, plus a null count. A reader + that wants launches priced under $60M can skip any row + group whose cheapest launch already costs more, without + opening it. A + reader that wants three columns of eight opens three chunks. + Skipping is built into the layout. +

+ +

+ The price is that Parquet files are written, not appended + to. The footer is written last and the file can't be read + without it, so a half-written Parquet file is unreadable and + adding a record means writing a new file. Writers buffer a + whole row group in memory before they emit it. Parquet suits + data you write in batches and read many times. +

+ +

What a read actually touches

+ +

+ Here's the same question asked of all three files: the + company, price and outcome of every SpaceX launch. It needs + three of the eight columns. +

+ +
+ Share of each file a reader has to touch to read three columns. JSON Lines: 100 percent. Avro: 100 percent. Parquet: 8 percent, the footer plus three of eight column chunks, 7,254 of 93,169 bytes. +
+ The Parquet bar is the file's real layout, from its own + metadata. Green is what a reader needs. +
+
+ +

+ JSON Lines and Avro have to go through the whole file. + Parquet reads 7,254 bytes of 93,169, because the footer + points straight at the three chunks that matter. On a file + this size nobody would notice. On a 2 GB file with 110 + columns, reading a handful of them is a very different job + in each format. +

+ +

+ Compression doesn't change the picture for JSON Lines. Gzip + takes this file to 81 KB, but a reader still has to inflate + all of it to reach any one field. +

+ +

Side by side

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
JSON LinesAvroParquet
LayoutRows, as textRows, in binary blocksColumns, in binary chunks
SchemaNoneIn the headerIn the footer
TypesStrings, numbers, booleans, null, nestedTimestamps, decimals, enums, nestedTimestamps, decimals, nested, lists
Add a recordAppend a lineAppend a blockWrite a new file
Read a few columnsScan every lineInflate every blockRead just those chunks
Skip rowsNoNoYes, by min/max
Open in a text editorYesNoNo
Best atLogs, API exports, anything a person readsEvent streams, records passed between servicesAnalytics, data lakes
+
+ +

Which one should you use?

+ +

+ Ask how the data will be written, then how it will be read. +

+ + + +

+ The mistake is treating this as one decision for the whole + pipeline. Most pipelines use two of the three, because + writing and reading want opposite things. +

+ +
+ Producers write to Avro event streams and JSON Lines logs, which are compacted in batches into Parquet files in object storage, which queries read a few columns at a time. +
+ +

+ A row format at the edge takes records as they happen. A + batch job compacts them into Parquet. Everything downstream + reads Parquet. The formats don't compete; they hand off to + each other. +

+ +

Where rugo fits

+ +

+ That hand-off is where Python code spends its life, and it's + why rugo reads all three formats + through one API. You pass a path or bytes, name the + columns you want and, where the format allows + it, the predicates to filter by. What comes + back is the same columnar batch whichever format it came + from. +

+ +
from rugo import avro, jsonl, parquet
+
+want = ["Company", "Price", "Mission_Status"]
+spacex = [("Company", "==", "SpaceX")]
+
+# Parquet: unwanted chunks and row groups are never read
+with parquet.read_parquet("space_missions.parquet", columns=want, predicates=spacex) as reader:
+    print(sum(m.num_rows for m in reader))
+
+# JSON Lines: unwanted fields are never turned into values
+with jsonl.read_jsonl(
+    "space_missions.jsonl", columns=want, predicates=spacex,
+    explicit_schema={"Price": "FLOAT64"},
+) as reader:
+    print(sum(m.num_rows for m in reader))
+
+# Avro: same columns; Avro has no statistics, so filter the values
+with avro.read_avro("space_missions.avro", columns=want) as reader:
+    print(sum(c == "SpaceX" for m in reader for c in m.column("Company").to_pylist()))
+ +

+ All three print 182. Moving data along the + pipeline in the previous section is a read and a write, + because every reader and writer shares the same batch: +

+ +
with jsonl.read_jsonl("events.jsonl") as reader:
+    with open("events.parquet", "wb") as f:
+        with parquet.open_parquet_writer(f.write) as writer:
+            for morsel in reader:
+                writer.write_row_group(morsel)
+ +

+ rugo is built on the idea at the centre of this post: the + fastest bytes are the ones you never read. That's why its + numbers look the way they do: +

+ + + +

+ The home page has the + measurements behind each figure, with versions and + hardware. +

+ +

Try it

+ +

+ pip install rugo is the whole install. Each + format has a notebook that runs in your browser on the same + 4,630 launches used here: + Parquet, + Avro + and + JSON Lines. +

+ +

Reproducing this

+ +

+ File sizes are the bytes on disk of + space_missions.jsonl, + .avro (deflate) and .parquet + (zstd) in + docs/data. The Avro file stores the rocket as a nested record; the + JSON Lines and Parquet files flatten it, and all three hold + the same 4,630 launches. The Parquet byte counts in the + reading diagram are each column chunk's compressed size and + the footer size from the file's own metadata, and the Avro + block count is from its block headers. The 182 comes from + running the code above with rugo 0.5.4. +

+ +

+ The diagrams are generated by + tools/build_format_diagrams.py, which reads the file sizes from the data files directly. +

+
+
+ + + + + + diff --git a/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-1-sizes.png b/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-1-sizes.png new file mode 100644 index 0000000..9bd142a Binary files /dev/null and b/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-1-sizes.png differ diff --git a/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-2-rows-vs-columns.png b/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-2-rows-vs-columns.png new file mode 100644 index 0000000..2fe1cfd Binary files /dev/null and b/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-2-rows-vs-columns.png differ diff --git a/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-3-jsonl.png b/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-3-jsonl.png new file mode 100644 index 0000000..132f99e Binary files /dev/null and b/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-3-jsonl.png differ diff --git a/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-4-avro.png b/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-4-avro.png new file mode 100644 index 0000000..d790a15 Binary files /dev/null and b/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-4-avro.png differ diff --git a/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-5-parquet.png b/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-5-parquet.png new file mode 100644 index 0000000..334a49c Binary files /dev/null and b/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-5-parquet.png differ diff --git a/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-6-reading.png b/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-6-reading.png new file mode 100644 index 0000000..f12361b Binary files /dev/null and b/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-6-reading.png differ diff --git a/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-7-pipeline.png b/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-7-pipeline.png new file mode 100644 index 0000000..4619aa1 Binary files /dev/null and b/docs/blog/img/avro-vs-parquet-vs-jsonl/fig-7-pipeline.png differ diff --git a/docs/blog/index.html b/docs/blog/index.html index d8c6af5..e7fae98 100644 --- a/docs/blog/index.html +++ b/docs/blog/index.html @@ -31,6 +31,20 @@

Blog

PyArrow, pandas or NumPy. Measurements, not estimates.

+ +

+ Avro vs Parquet vs JSON Lines: one dataset, three very + different files +

+

20 October 2026

+

+ The same 4,630 rows take 1.09 MB as JSON Lines, 113 KB + as Avro and 93 KB as Parquet. Size is the least + interesting difference: what matters is how much of + each file a reader can skip. +

+
+

Performant Python pipelines without the Rust

18 October 2026

diff --git a/medium/avro-vs-parquet-vs-jsonl/table-1.png b/medium/avro-vs-parquet-vs-jsonl/table-1.png new file mode 100644 index 0000000..aa8fd43 Binary files /dev/null and b/medium/avro-vs-parquet-vs-jsonl/table-1.png differ diff --git a/tools/build_format_diagrams.py b/tools/build_format_diagrams.py new file mode 100644 index 0000000..bfe1315 --- /dev/null +++ b/tools/build_format_diagrams.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +"""Draw the diagrams for the "Avro vs Parquet vs JSON Lines" post. + + uv run --with pillow python tools/build_format_diagrams.py + +Each figure is hand-built SVG with its own dark background, so it reads the same +on rugo.dev and on Medium (which has no tables, no inline SVG and no dark mode). +The SVG is rendered to a PNG at 2x with headless Microsoft Edge, and the PNGs are +what the post links. File sizes come straight from docs/data; the Parquet column +chunk sizes and footer size were read from the file's own metadata with PyArrow +(`ParquetFile(...).metadata.row_group(0).column(i).total_compressed_size`), and +the Avro block count from fastavro's block_reader. +""" +import os, subprocess, tempfile, textwrap +from html import escape + +from PIL import Image + +EDGE = "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" +ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) +DATA = f"{ROOT}/docs/data" +OUT = f"{ROOT}/docs/blog/img/avro-vs-parquet-vs-jsonl" + +# Palette: the blog's own tokens. The three format colours passed +# validate_palette.js on the dark surface (CVD dE 9.1, normal-vision dE 24.3) and +# every mark is also labelled directly, so colour is never the only signal. +BG, SURFACE, BORDER = "#161920", "#0d0f14", "#252830" +TEXT, MUTED = "#e2e6f0", "#8a90a8" +SKIP = "#2a2f3a" +JSONL, AVRO, PARQUET = "#e8873a", "#4f8ef7", "#3ecf8e" +COLS = ["#8b7cf6", "#2fb7c9", "#e0607e"] # A, B, C in the row/column figure +SANS = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" +MONO = "ui-monospace, 'SF Mono', Menlo, Consolas, monospace" +W = 760 + + +def svg(height, body): + return (f'' + f'' + f'{body}') + + +def rect(x, y, w, h, fill, stroke=None, rx=4, dash=False): + s = f' stroke="{stroke}"' if stroke else "" + d = ' stroke-dasharray="4 3"' if dash else "" + return f'' + + +def text(x, y, s, size=13, fill=TEXT, weight=400, anchor="start", mono=False): + fam = f' font-family="{MONO}"' if mono else "" + return (f'{escape(s)}') + + +def para(x, y, s, width, size=12.5, fill=MUTED, lead=18): + lines = textwrap.wrap(s, width) + return "".join(text(x, y + i * lead, ln, size, fill) for i, ln in enumerate(lines)) + + +def line(x1, y1, x2, y2, color=MUTED, dash=True): + d = ' stroke-dasharray="4 3"' if dash else "" + return f'' + + +def arrow(x1, y1, x2, y2, color=MUTED): + return (line(x1, y1, x2, y2, color, dash=False) + + f'') + + +def title(t, sub=None): + out = text(28, 40, t, 19, TEXT, 700) + if sub: + out += text(28, 62, sub, 13, MUTED) + return out + + +# ── 1. Same data, three files ──────────────────────────────────────────────── +def fig_sizes(): + sizes = {k: os.path.getsize(f"{DATA}/space_missions.{k}") for k in ("jsonl", "avro", "parquet")} + top = sizes["jsonl"] + rows = [("JSON Lines", JSONL, sizes["jsonl"]), ("Avro", AVRO, sizes["avro"]), + ("Parquet", PARQUET, sizes["parquet"])] + b = title("The same 4,630 launches, three files", "Bytes on disk, drawn to scale") + x0, full = 150, 430 + for i, (name, color, n) in enumerate(rows): + y = 96 + i * 52 + w = max(full * n / top, 3) + b += text(28, y + 17, name, 14, TEXT, 600) + b += rect(x0, y, w, 24, color, rx=4) + label = f"{n/1e6:.2f} MB" if n >= 1e6 else f"{round(n/1e3)} KB" + if i: + label += f" · {top/n:.1f}× smaller" + b += text(x0 + w + 10, y + 17, label, 13.5, TEXT) + b += text(28, 262, "JSON Lines as written, Avro with deflate, Parquet with zstd. Same rows, same fields.", + 12, MUTED) + return svg(284, b), sizes + + +# ── 2. Row vs column ───────────────────────────────────────────────────────── +def fig_row_vs_col(): + b = title("Row-oriented or column-oriented", "Three records, three fields. The same values, stored in a different order.") + names = ["Company", "Rocket", "Price"] + cw, ch, x0, y0 = 92, 30, 28, 84 + for c, n in enumerate(names): + b += text(x0 + c * (cw + 4) + cw / 2, y0 + 18, n, 12, MUTED, 600, "middle") + for r in range(3): + for c in range(3): + x, y = x0 + c * (cw + 4), y0 + 26 + r * (ch + 4) + b += rect(x, y, cw, ch, COLS[c]) + b += text(x + cw / 2, y + 20, f"{'ABC'[c]}{r+1}", 13, SURFACE, 700, "middle") + b += para(364, y0 + 38, "Logically it is a table. On disk it has to become one long line of bytes, " + "and the order you pick decides which reads are cheap.", 46, 14, TEXT, 21) + + def strip(y, label, order, groups, glabels, note): + s = text(70, y, label, 14, TEXT, 700) + w, g, gg = 64, 3, 14 + x = (W - (3 * (3 * w + 2 * g) + 2 * gg)) / 2 + for gi in range(3): + gx = x + for cell in order[gi]: + s += rect(x, y + 12, w, 34, COLS["ABC".index(cell[0])]) + s += text(x + w / 2, y + 34, cell, 13, SURFACE, 700, "middle") + x += w + g + s += text((gx + x - g) / 2, y + 66, glabels[gi], 12, MUTED, 400, "middle") + x += gg - g + s += text(70, y + 92, note, 12.5, MUTED) + return s + + rows = [["A1", "B1", "C1"], ["A2", "B2", "C2"], ["A3", "B3", "C3"]] + cols = [["A1", "A2", "A3"], ["B1", "B2", "B3"], ["C1", "C2", "C3"]] + b += strip(262, "Row-oriented: JSON Lines, Avro", rows, 3, ["record 1", "record 2", "record 3"], + "Adding a record is cheap. Reading one column means stepping through every record.") + b += strip(392, "Column-oriented: Parquet", cols, 3, ["Company", "Rocket", "Price"], + "Reading one column is one contiguous run. Skipping the others is free.") + return svg(520, b) + + +# ── 3. JSON Lines anatomy ──────────────────────────────────────────────────── +def fig_jsonl(): + b = title("JSON Lines: one record per line", "The first three rows of the dataset (4 of the 8 fields shown)") + b += rect(28, 76, 704, 112, SURFACE, BORDER, rx=8) + recs = [("RVSN USSR", "Sputnik 8K71PS", "null", "Success"), + ("RVSN USSR", "Sputnik 8K71PS", "null", "Success"), + ("US Navy", "Vanguard", "null", "Failure")] + for i, (co, ro, pr, st) in enumerate(recs): + y = 108 + i * 30 + b += text(46, y, str(i + 1), 12, MUTED, mono=True) + k = lambda s: f'"{s}"' + v = lambda s: f'"{s}"' + b += (f'' + f'{{{k("Company")}:{v(co)},{k("Rocket")}:{v(ro)},{k("Price")}:{pr},' + f'{k("Mission_Status")}:{v(st)}}}') + b += text(732 - 12, 204, "keys in orange", 11.5, MUTED, anchor="end") + cards = [("Self-describing", "Every line carries its own keys, so any line can be read alone. " + "The keys are also paid for 4,630 times."), + ("Append-friendly", "A new record is a new line. There is no header to update and no footer to rewrite."), + ("Everything is text", "Numbers, timestamps and nulls are strings to parse, " + "and there is no schema to check them against.")] + for i, (h, body) in enumerate(cards): + x = 28 + i * 240 + b += rect(x, 224, 224, 128, BG, BORDER, rx=8) + b += rect(x, 224, 224, 3, JSONL, rx=1.5) + b += text(x + 14, 252, h, 14, TEXT, 700) + b += para(x + 14, 274, body, 30, 12.5, MUTED, 18) + return svg(376, b) + + +# ── 4. Avro anatomy ────────────────────────────────────────────────────────── +def fig_avro(): + b = title("Avro: a header, then blocks", "The sample file: 4,630 records in 29 blocks, about 160 records per block") + y, h = 80, 44 + b += rect(28, y, 112, h, AVRO) + text(84, y + 27, "Header", 14, SURFACE, 700, "middle") + xs = [144, 268, 392] + for i, x in enumerate(xs): + b += rect(x, y, 120, h, SURFACE, AVRO) + text(x + 60, y + 27, f"Block {i+1}", 13.5, TEXT, 600, "middle") + b += text(528, y + 28, "…", 18, MUTED, 600, "middle") + b += rect(544, y, 120, h, SURFACE, AVRO) + text(604, y + 27, "Block 29", 13.5, TEXT, 600, "middle") + # connectors + b += line(84, y + h, 84, 172) + line(452, y + h, 452, 172) + # header detail + b += rect(28, 172, 330, 204, BG, BORDER, rx=8) + rect(28, 172, 330, 3, AVRO, rx=1.5) + b += text(44, 202, "Header: once per file", 14, TEXT, 700) + items = [("magic", "Obj + version byte"), ("schema", "JSON: every field name and type"), + ("codec", "deflate (or null, snappy, zstd…)"), ("sync marker", "16 random bytes")] + for i, (k, v) in enumerate(items): + yy = 232 + i * 30 + b += text(44, yy, k, 12.5, MUTED) + text(138, yy, v, 12.5, TEXT) + b += para(44, 350, "The schema travels with the data, so the file explains itself.", 52, 12.5, MUTED, 18) + # block detail + b += rect(382, 172, 350, 204, BG, BORDER, rx=8) + rect(382, 172, 350, 3, AVRO, rx=1.5) + b += text(398, 202, "Block: repeated", 14, TEXT, 700) + parts = [("count", 52), ("size", 48), ("records, compressed", 150), ("sync", 52)] + x = 398 + for name, w in parts: + fill = SURFACE if name != "records, compressed" else "#1b2740" + b += rect(x, 216, w, 36, fill, AVRO) + text(x + w / 2, 239, name, 11.5, TEXT, 500, "middle") + x += w + 4 + b += para(398, 280, "Records sit back to back with no keys and no tags. The schema says what every byte means.", + 48, 12.5, MUTED, 18) + b += para(398, 326, "Each block is compressed alone and ends with the sync marker, so a reader can " + "start at any block.", 48, 12.5, MUTED, 18) + return svg(400, b) + + +# ── 5. Parquet anatomy ─────────────────────────────────────────────────────── +CHUNKS = [("Company", 3108), ("Location", 5686), ("Price", 1759), ("Lauched_at", 37261), + ("Rocket", 7579), ("Rocket_Status", 384), ("Mission", 34226), ("Mission_Status", 691)] +FOOTER = 1688 +READ = {"Company", "Price", "Mission_Status"} + + +def fig_parquet(): + b = title("Parquet: column chunks, and a footer that maps them", "The sample file has one row group; larger files have many") + y, h = 80, 44 + b += rect(28, y, 52, h, SURFACE, PARQUET) + text(54, y + 27, "PAR1", 12, TEXT, 600, "middle", True) + b += rect(84, y, 170, h, SURFACE, PARQUET) + text(169, y + 27, "Row group 1", 13.5, TEXT, 600, "middle") + b += rect(258, y, 170, h, SURFACE, PARQUET) + text(343, y + 27, "Row group 2", 13.5, TEXT, 600, "middle") + b += text(452, y + 28, "…", 18, MUTED, 600, "middle") + b += rect(476, y, 140, h, PARQUET) + text(546, y + 27, "Footer", 14, SURFACE, 700, "middle") + b += rect(620, y, 112, h, SURFACE, PARQUET) + text(676, y + 27, "len · PAR1", 12, TEXT, 600, "middle", True) + b += line(169, y + h, 169, 168) + line(546, y + h, 546, 340) + # row group detail + b += rect(28, 168, 704, 150, BG, BORDER, rx=8) + rect(28, 168, 704, 3, PARQUET, rx=1.5) + b += text(44, 196, "Row group 1: one chunk per column", 14, TEXT, 700) + b += text(716, 196, "each chunk is a run of compressed pages", 11.5, MUTED, anchor="end") + cw, gap, x = 82, 4, 38 + for name, size in CHUNKS: + sel = name in READ + b += rect(x, 210, cw, 50, PARQUET if sel else SURFACE, None if sel else BORDER, dash=not sel) + col = SURFACE if sel else MUTED + parts = name.split("_") + if len(parts) == 2 and len(name) > 11: + b += text(x + cw / 2, 232, parts[0] + "_", 10.5, col, 600, "middle") + b += text(x + cw / 2, 246, parts[1], 10.5, col, 600, "middle") + else: + b += text(x + cw / 2, 239, name, 10.5, col, 600, "middle") + b += text(x + cw / 2, 276, f"{size/1000:.1f} KB", 11.5, TEXT if sel else MUTED, 500, "middle") + x += cw + gap + b += text(44, 304, "Filled: the three chunks a query for Company, Price and Mission_Status reads. " + "Dashed: never touched.", 12, MUTED) + # footer detail + b += rect(28, 340, 704, 112, BG, BORDER, rx=8) + rect(28, 340, 704, 3, PARQUET, rx=1.5) + b += text(44, 368, "Footer: read first", 14, TEXT, 700) + b += text(44, 394, "schema", 12.5, MUTED) + text(150, 394, "every column's name and type", 12.5, TEXT) + b += text(44, 418, "per chunk", 12.5, MUTED) + text(150, 418, "byte offset · size · min · max · null count", 12.5, TEXT) + b += text(44, 442, "for example", 12.5, MUTED) + text(150, 442, "Price: 1,759 bytes, min 2.5, max 450.0", + 12.5, TEXT) + return svg(472, b) + + +# ── 6. Reading three columns ───────────────────────────────────────────────── +def fig_reading(sizes): + b = title("Reading 3 columns of 8", "") + b += text(28, 62, "SELECT Company, Price, Mission_Status WHERE Company = 'SpaceX'", 12.5, MUTED, mono=True) + x0, full = 28, 704 + for i, (name, color, size, pct, desc) in enumerate([ + ("JSON Lines", JSONL, sizes["jsonl"], "100% of the file", + f"All {sizes['jsonl']:,} bytes are scanned. Fields you don't name are skipped, but every line is walked."), + ("Avro", AVRO, sizes["avro"], "100% of the file", + f"All {sizes['avro']:,} bytes: every one of the 29 blocks is inflated and walked."), + ("Parquet", PARQUET, sizes["parquet"], None, None)]): + base = 92 + i * 84 + b += text(28, base + 14, name, 14, TEXT, 700) + if name != "Parquet": + b += text(732, base + 14, pct, 13, TEXT, 600, "end") + b += rect(x0, base + 22, full, 22, color) + text(28, base + 64, desc, 12, MUTED) + continue + total = size + read_bytes = sum(s for n, s in CHUNKS if n in READ) + FOOTER + 8 + segs = [("magic", 4, False)] + [(n, s, n in READ) for n, s in CHUNKS] + segs.append(("other", total - 4 - sum(s for _, s in CHUNKS) - FOOTER - 8, False)) + segs.append(("footer", FOOTER + 8, True)) + x = x0 + for n, s, sel in segs: + w = full * s / total + b += rect(x, base + 22, max(w - 2, 1.5), 22, PARQUET if sel else SKIP, rx=2 if w > 6 else 0) + x += w + b += text(732, base + 14, f"{read_bytes / total:.0%} of the file", 13, TEXT, 600, "end") + b += text(28, base + 64, f"The footer, then 3 of 8 column chunks: {read_bytes:,} of {total:,} bytes. " + "The rest is never read.", 12, MUTED) + return svg(364, b) + + +# ── 7. Pipeline ────────────────────────────────────────────────────────────── +def fig_pipeline(): + b = title("A common shape: row formats in, Parquet at rest") + + def box(x, y, w, h, head, sub, color, wrap=26): + return (rect(x, y, w, h, SURFACE, color, rx=8) + rect(x, y, 4, h, color, rx=2) + + text(x + 18, y + 26, head, 14, TEXT, 700) + para(x + 18, y + 46, sub, wrap, 12, MUTED, 16)) + + b += box(28, 112, 150, 72, "Producers", "apps, services, devices", MUTED, 22) + b += box(232, 82, 190, 66, "Avro", "event streams, queues", AVRO) + b += box(232, 164, 190, 66, "JSON Lines", "logs, API exports", JSONL) + b += box(480, 112, 124, 72, "Parquet", "object storage", PARQUET) + b += box(644, 112, 88, 72, "Reads", "queries", MUTED, 12) + b += arrow(178, 136, 230, 115) + arrow(178, 160, 230, 197) + b += arrow(422, 115, 478, 136) + arrow(422, 197, 478, 160) + b += arrow(604, 148, 642, 148) + b += text(232, 74, "append one record at a time", 12, MUTED) + b += text(480, 104, "compact in batches", 12, MUTED) + b += text(644, 202, "a few columns,", 12, MUTED) + text(644, 218, "many times", 12, MUTED) + return svg(250, b) + + +def render(name, svg_text, tmp): + page = f"{tmp}/{name}.html" + open(page, "w", encoding="utf-8").write( + f'{svg_text}') + raw = f"{tmp}/{name}-raw.png" + height = int(svg_text.split('height="')[1].split('"')[0]) + subprocess.run([EDGE, "--headless=new", "--disable-gpu", "--hide-scrollbars", + "--force-device-scale-factor=2", f"--window-size={W},{height + 120}", + f"--screenshot={raw}", f"file://{page}"], check=True, capture_output=True) + img = Image.open(raw).convert("RGB").crop((0, 0, W * 2, height * 2)) + dest = f"{OUT}/{name}.png" + img.save(dest, optimize=True) + print(f"{dest} {img.width}x{img.height}") + + +if __name__ == "__main__": + os.makedirs(OUT, exist_ok=True) + sizes_svg, sizes = fig_sizes() + figs = [("fig-1-sizes", sizes_svg), ("fig-2-rows-vs-columns", fig_row_vs_col()), + ("fig-3-jsonl", fig_jsonl()), ("fig-4-avro", fig_avro()), ("fig-5-parquet", fig_parquet()), + ("fig-6-reading", fig_reading(sizes)), ("fig-7-pipeline", fig_pipeline())] + with tempfile.TemporaryDirectory() as tmp: + for name, s in figs: + render(name, s, tmp)