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

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
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [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]
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]
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.
- [4]
dictionary_key_size_threshold defaults to 0.0, which disables dictionary encoding entirely; the setting is worth 14% of the file from one keyword argument.
- [5]
ORC keeps min/max statistics at three zoom levels: file (448 bytes), stripe (663 bytes) and row group of 10,000 rows (8.4 KB per stripe).
- [6]
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.
- [7]
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).
- [8]
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.
- [9]
The ORC file is 53 MB; the same data written as CSV is 310 MB.
- [10]
The last byte of an ORC file is a single number, the length of the PostScript; the final 26 bytes of the author's file are a Protocol Buffers message.
- [11]
A query engine does three reads: the last byte, the PostScript, then the footer plus metadata, amounting to a few hundred bytes on the 53 MB file.
- [12]
After those three reads the engine knows the schema, the row count, every stripe's byte offset and min/max statistics for every column, without having touched a single row of data.
- [13]
The codec is recorded in the PostScript, and the PostScript is never compressed because you would need the codec to decompress the thing that tells you the codec.
- [14]
A stripe is a self-contained slab of rows with its own index, data and footer, so a Spark executor handed one byte range can decode that stripe with zero knowledge of the rest of the file; that is the basis of parallel scans.
- [15]
Stripe 0 of the author's file has 8,412 bytes of index, 8,544,070 bytes of data, a 159-byte footer and 687,104 rows.
- [16]
The author characterises 8,412 bytes of index for 8.5 MB of data as 0.1% overhead for the ability to skip, and calls that ratio the entire trade.
- [17]
Within a stripe, column 3 (country) is stored as DICTIONARY_DATA of 23 bytes, LENGTH of 5 bytes and DATA of 310,404 bytes; column 6 (price) has DATA of 2,374,052 bytes.
- [18]
All streams for one column sit contiguously, so reading country from stripe 3 is one range read rather than 687,104 scattered seeks.
- [19]
ORC omits the PRESENT stream entirely when a column has no nulls; because the author's columns are all non-null, null tracking cost zero bytes, whereas a row format pays for nullability on every row whether used or not.
- [20]
The order_date column, 5 million values, occupies 7,390,184 bytes in random order and 4,898 bytes when sorted, which the author reports as 1,500x smaller.
- [21]
Five million 64-bit integers are 40,000,000 bytes raw, so storing them in 11 KB is a reduction of roughly 3,600x before the codec runs.
- [22]
Only after encoding does the codec run, in independent 64 KB chunks each with a 3-byte header; bit 0 marks a chunk as stored raw, because if compressing a chunk made it bigger ORC does not compress it.
- [23]
Chunking is what makes seeking possible; one big compressed blob would mean inflating 8 MB to read the last value.
- [24]
Uncompressed ORC is still 3.6x smaller than CSV, a gap the author attributes to pure layout and encoding.
- [25]
The 53 MB zlib-compressed ORC file is about 5.8x smaller than the 310 MB CSV of the same data.
- [26]
The author reports that zstd wins on all three axes measured, and that zlib is only still the default because ORC is old.
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.
Sources
1 independent publisher whose own reporting we read for this story.
- dev.toUnderstanding the ORC File Format: Why Is It So Fast
1 article · August 21, 2026
Topics and entities
Follow any of these and your For You feed starts watching them — no settings page required.