Skip to content

Build Not yet confirmed elsewhere1 publisher 3 min read Published

The codec runs last: what a hand-written ORC parser shows about columnar speed

A 5-million-row file, opened byte by byte, puts the credit where it belongs: column locality, neighbour-dependent encoding, and metadata you can skip.

The Engineer · Build desk

How we use AI Send a correction

Photograph accompanying The codec runs last: what a hand-written ORC parser shows about columnar speed
Photo: trino.io

What happened

  • The author wrote a small ORC parser from scratch, generated 5 million rows in a deliberately boring orders table, and opened the resulting file by hand, rather than accepting the standard explanation of columnar storage, built-in indexes and predicate pushdown.
  • The author reduces ORC's speed to three things: store each column's values together so one column can be read without touching others; encode values in ways that only work when neighbours are similar; and keep a small map of what is inside so a query can skip most of the file. Compression codecs, bloom filters and ACID support are built around that core.
  • The test table has 5,000,000 rows and columns order_id (int64, sequential), customer_id (int64, 250k distinct), country (string, 10 distinct), product (string, 2,000 distinct), quantity (int32, 1-19), price (double, 40k distinct), order_date (date, 730 days) and status (string, 5 distinct).
  • The file was written with PyArrow using compression="zlib", stripe_size=8*1024*1024, row_index_stride=10_000 and dictionary_key_size_threshold=0.8.
  • The ORC file is 53 MB; the same data written as CSV is 310 MB.

Why it matters

A developer wrote a small ORC parser from scratch, generated a deliberately boring 5-million-row orders table, and read the file by hand instead of repeating the usual summary about columnar storage and predicate pushdown [6]. What comes out of the byte-level walk is a reordering of credit: the compression codec only runs after encoding is done, and by then most of the size reduction has already happened [3][22]. Start with the read path. The last byte of an ORC file holds the length of the PostScript, and in this file the final 26 bytes decode as a Protocol Buffers message [10]. That means a query engine performs three reads, the last byte, the PostScript, then the footer plus metadata, totalling a few hundred bytes against a 53 MB file [11]. After those reads it has the schema, the row count, every stripe's byte offset, and min/max statistics for every column, without having touched a row [12]. The ordering is forced rather than clever: the codec name lives in the PostScript, and the PostScript cannot itself be compressed, because you would need the codec to decompress the thing that names the codec [13]. Locality is equally mechanical. Inside a stripe a column is a set of streams laid out contiguously, so the country column appears as 23 bytes of DICTIONARY_DATA, 5 bytes of LENGTH and 310,404 bytes of DATA, while price DATA is 2,374,052 bytes [17]. Reading country from one stripe is a single range read rather than 687,104 scattered seeks [18]. Nullability is priced the same way: the PRESENT stream is omitted entirely when a column has no nulls, so null tracking here cost zero bytes, where a row format pays on every row whether you use it or not [19]. The encoding numbers are the part worth arguing about. Five million sequential 64-bit integers occupy 11 KB, because ORC's RLE v2 picked its DELTA sub-encoding and stored a base, a delta width and a run length [3]. Raw, those values are 40 MB, so the encoder cut roughly 3,600x before zlib was invited in [21]. Sort order is the lever: the same 5 million dates take 7,390,184 bytes in random order and 4,898 bytes sorted, which the author measures as 1,500x [20]. Compression that only works when neighbours are similar means physical row order is a schema decision, not a cosmetic one. Two defaults deserve attention. PyArrow's dictionary_key_size_threshold defaults to 0.0, which disables dictionary encoding outright; setting it to 0.8 was worth 14% of the file from one keyword argument [4][8]. And the codec runs in independent 64 KB chunks with a 3-byte header, where bit 0 marks a chunk as stored raw if compressing it made it bigger, which is also what makes seeking cheap: one large compressed blob would mean inflating 8 MB to read the last value [22][23]. Read the setup before borrowing the conclusions. Stripe size was set to 8 MB on purpose, because production ORC uses 64 to 256 MB stripes and a default stripe would have swallowed this whole 53 MB dataset, leaving nothing to skip [2][8]. The skip machinery is cheap at this scale: 8,412 bytes of index against 8,544,070 bytes of data in stripe 0, which the author puts at 0.1% overhead [15][16], with min/max statistics kept at three zoom levels, 448 bytes for the file, 663 for a stripe, and 8.4 KB per stripe for row groups of 10,000 rows [5]. Uncompressed ORC is still 3.6x smaller than the equivalent 310 MB CSV, a gap the author attributes purely to layout and encoding [24][9]. Worth checking on your own writers: whether dictionary encoding is switched on at all, whether the columns you filter by are the columns you sort by, and whether your stripe size leaves more than one stripe to skip.

Clarity's read

What the record supports and how the coverage leans. The claims behind it follow.

Reality

Evidence61
Adoption
Insufficient
Hype gap−12
Incentives24
Confidence56
Why these scores

Claim ledger

Ranked by verification strength, evidence, and original report placement.

  1. [1]

    The author reduces ORC's speed to three things: store each column's values together so one column can be read without touching others; encode values in ways that only work when neighbours are similar; and keep a small map of what is inside so a query can skip most of the file. Compression codecs, bloom filters and ACID support are built around that core.

  2. [2]

    Production ORC uses 64-256 MB stripes; because the author's whole file is 53 MB, a default stripe would swallow the entire dataset and leave nothing to skip, so the stripe size was shrunk to make multi-stripe behaviour visible on a laptop.

  3. [3]

    Five million sequential 64-bit integers occupy 11 KB, because ORC's RLE v2 picks its DELTA sub-encoding and stores a base, a delta width and a run length; zlib never saw 40 MB of integers because the encoder never produced them.

Get this ledger inside ChatGPT. Ask about any of our stories and the answer brings its figure, each claim’s verdict and the sources. Free, no account.

Add to ChatGPTAdd to Claude

Sources

1 independent publisher whose own reporting we read for this story.

  1. dev.to

    1 article · August 21, 2026

    Understanding the ORC File Format: Why Is It So Fast

Share your take

Let Clarity write the post for you.

Signed-in readers get a short post drafted on this story in the register they choose — narrative, analytical, or a direct position — editable to the last word before it goes anywhere. The share buttons at the top of this story work without an account.

Topics and entities

Follow any of these and your For You feed starts watching them — no settings page required.

Topics

Loading related stories