studyDDIAChapter 4 · Storage and Retrieval

Sorting Rows Changes How Big They Are

The same fact table is 5.5× smaller as a column store than as an uncompressed row store. Then reorder the exact same 10 million rows — changing no values — and it shrinks another 1.4×, with one column collapsing 30×. Sort order is a compression lever.


Concept

A column store keeps each column's values together, and analytic columns tend to have few distinct values. Like values sitting adjacent compress extremely well — so the same fact table is far smaller column-oriented than row-oriented. Then the twist: because a column store may keep rows in any order, sorting the table turns the sort columns into long runs, and run-length encoding shrinks them to almost nothing. You will reorder the exact same 10 million rows — not change a single value — and watch the file get 1.4× smaller, with one column collapsing 30×.

Provenance

Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 4 — Storage and Retrieval, §"Column compression" and §"Sort order in column storage":

Often the number of distinct values in a column is small compared to the number of rows (for example … billions of sales transactions, but only 100,000 distinct products).

The book explains bitmap/run-length encoding on low-cardinality columns, and that a column store can impose a sort order "as we did with SSTables." Here you measure both: the compression, and how much a chosen sort order adds.

Prerequisites

The surprise is about redundancy and adjacency. These help; each line notes where it shows up.

  1. Column-major layout — each column's values are stored contiguously, so a compressor sees a long run of same-typed, often-repeating values (unlike a row store, where a column's values are scattered one-per-row).
  2. Cardinality — the number of distinct values in a column. Low cardinality (731 dates, 200 stores) is what makes a column compressible.
  3. Run-length & dictionary/bitmap encoding — "value V repeated 40,000 times" stored as a count, not 40,000 copies; a small dictionary of distinct values plus compact codes. These encodings exploit #1 and #2.
  4. Sort order as a lever — a column store can store rows in any order. Sorting by a column groups its equal values into runs, which turns run-length encoding from "helpful" into "nearly free." Rows must be sorted whole (all columns together) so the k-th entry of every column still belongs to the same row.
Where to learn the prerequisites RLE / bitmap / dictionary encoding (#2–#3): DDIA §"Column compression" (Figure 4-8, bitmap encoding) is the clearest short version; the Apache Parquet "Encodings" docs list RLE/dictionary as used here. Sort order (#4): DDIA §"Sort order in column storage," and the "C-Store / Vertica" idea of storing multiple sort orders (projections). If only one is new, make it #3 — run-length encoding is why sorting pays off.
Environment these numbers came from Deterministic data, so every size below reproduces exactly. Captured on macOS 26.5.2 (Darwin 25.5.0), arm64, with DuckDB 1.5.5 on Python 3.12.13 (run via uv) and sqlite3 from the standard library as the row-store baseline — no Docker. One synthetic fact table, 10,000,000 rows × 5 low-cardinality columns.

Mental model

The fact table has five columns, all low-cardinality:

columndistinct values
date_key731 (two years)
product_sk5,000 (scattered)
store_sk200
promotion_sk~40, but mostly 0
quantity10

We write it three ways and compare sizes: a row store (SQLite, no compression), a column store (Parquet), and the same column store with the rows sorted by (date_key, store_sk, product_sk). Sorting changes no values — only the order rows are stored in. The whole exercise is one script, code/column_compression.py.

Setup

cd study/ddia/ch04/code
uv run --with duckdb python3 column_compression.py     # ~20 s: builds 10M rows, writes 3 files

Do not read the output yet — make each prediction first.


Step 1 · column vs row size (calibration)

Predict. Same 10M-row table, no schema change. Roughly how much smaller is the column-oriented (Parquet) file than the uncompressed row store (SQLite)?

How much smaller is the column store?
same data, three ways on disk: row store SQLite (uncompressed) = 190.4 MB column store Parquet (unsorted) = 34.4 MB (5.5x smaller than the row store) column store Parquet (sorted) = 24.3 MB (1.4x smaller again -- same data, just reordered)

5.5× smaller as a column store, before any sorting — because each column is a run of same-typed, low-cardinality values that dictionary/run-length encoding loves. Note the third line for the next step: sorting made it smaller again.

Step 2 · sorting the rows shrinks the file (the surprise)

Predict. The sorted file (24.3 MB) is smaller than the unsorted one (34.4 MB) — but it is the exact same 10 million rows with the exact same values, only in a different order. How can reordering rows change the file size? And which columns get smaller? (Most people expect row order to have no effect on size at all.)

Which columns shrink when you sort?
per-column compressed size, unsorted -> sorted by (date_key, store_sk, product_sk): column unsorted sorted effect of sorting date_key 0.66 MB 0.01 MB 59x smaller store_sk 9.75 MB 0.32 MB 30x smaller product_sk 17.20 MB 17.20 MB ~unchanged promotion_sk 1.74 MB 1.74 MB ~unchanged quantity 4.24 MB 4.24 MB ~unchanged

The whole 10 MB the file shed came from two columns. Sorting by date_key first turns it into 731 long runs — run-length encoding crushes it 59×, to essentially nothing (0.01 MB). Within each date, sorting by store_sk groups its 200 values into runs too: 30× smaller. product_sk — the last, finest sort key — is still scattered inside each little group, so it barely changes; promotion_sk and quantity weren't sorted on at all, so they don't move.

Step 3 · why only those columns

Predict. You sorted by three columns but only two shrank. Why does product_sk (also a sort key) stay 17.2 MB?

Why doesn't the third sort key compress?

Sorting produces runs only where values actually repeat consecutively. date_key and store_sk are the leading sort keys, so their equal values land in long contiguous blocks. product_sk is the tie-breaker: within a single (date, store) group there are few rows and 5,000 possible products, so consecutive product_sk values rarely repeat — no runs, no RLE win. The compression benefit of a sort order lands almost entirely on its leading columns.


What you should see

Why

Compression feeds on redundancy that sits close together. A column store already wins because a column is a long, uniform run of same-typed values — and when the cardinality is low (731 dates among 10M rows), dictionary and run-length encoding replace millions of repeats with a handful of codes and counts. That is the 5.5× in Step 1, and why date_key was already tiny (0.66 MB) even unsorted.

Sorting adds a second, larger win, and it is purely about adjacency. Run-length encoding stores "value V, ×N" only when V's copies are consecutive. Unsorted, a column's equal values are sprinkled throughout the file; sorting the table gathers them into long contiguous runs. Sort by date_key and the column becomes 731 runs — 0.01 MB. Sort by store_sk next and, within each date, its 200 values form runs too — 30× smaller. Crucially, the rows are sorted as whole rows: you cannot sort each column independently or the k-th entries would no longer belong to the same row. So one physical sort order is imposed on the whole table, and its benefit falls on the leading columns; the tie-breaker product_sk still has no local repetition and does not compress.

The boundary — you get one sort order, so choose it Sorting helps the columns you sort by (and any column correlated with them), and only the leading ones dramatically. A high-cardinality, uncorrelated column like product_sk won't compress no matter where you put it in the sort. And a table has a single physical order per copy — you're spending it. That is both a compression lever and an indexing decision: the same sort that shrinks date_key to nothing also lets a "last month" query scan a contiguous block (the SSTable-style ordering the book points back to). Systems like Vertica store several sort orders as separate projections precisely because one order can't be optimal for everything.

Go deeper

Sources: DDIA 2e, Ch. 4, §"Column compression" (Figure 4-8) and §"Sort order in column storage" · Apache Parquet "Encodings" (RLE / dictionary) · DuckDB.