studyDDIAChapter 4 · Storage and Retrieval

The Column Store Reads Only What It Needs

A query over a 30-column, 10-million-row table that references just 2 columns makes a row store read 100% of the data and a column store read 0.6%165× less — purely from row-major vs column-major layout. And the 26 unused columns cost the column store nothing.


Concept

A data-warehouse fact table is often ~100 columns wide, but a typical analytics query touches only a handful. In a row store, a table scan reads every column of every row — even the 28 you never mention — because a row's columns are stored contiguously. A column store keeps each column separately, so it reads only the columns the query references. On a 30-column, 10-million-row table you will run a query that needs just 2 columns and watch the column store read 0.6% of the data the row store must read — and answer ~180× faster.

Provenance

Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 4 — Storage and Retrieval, §"Column-Oriented Storage":

Although fact tables are often over one hundred columns wide, a typical data warehouse query accesses only four or five of them at one time.

Example 4-1 in the book is exactly this: a query over the whole fact_sales table that needs only date_key, product_sk, and quantity. Here you measure what row-major vs column-major layout does to the bytes such a query must read.

Prerequisites

The surprise is about physical layout, not query engines. These help; each line notes where it shows up.

  1. Row-major vs column-major storage — a row store keeps a row's columns next to each other on disk; a column store keeps all of one column's values together. This single choice is the whole exercise.
  2. What a table scan reads — to aggregate a column with no usable index, the engine reads the table's pages start to finish. In a row store those pages hold every column, interleaved.
  3. Column pruning (projection pushdown) — a column store can skip the file regions for columns a query doesn't reference. The row store cannot; the unwanted columns share pages with the wanted ones.
  4. Parquet — a columnar file format that stores each column as its own chunk with its own size, which is how we measure "bytes the query must read" exactly.
Where to learn the prerequisites Row vs column stores (#1–#3): DDIA §"Column-Oriented Storage" itself is the best short treatment; the DuckDB and ClickHouse docs both have clear "why columnar" explainers. Parquet layout (#4): the Apache Parquet format docs (row groups → column chunks → pages) — enough to see why per-column sizes are readable from the file's metadata. If only one is new, make it #1 — everything follows from row-major vs column-major.
Environment these numbers came from Deterministic data, so the sizes and bytes-read reproduce exactly; wall-clock timings depend on your hardware and will differ (the ratio is the point). 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 — no Docker. One synthetic fact table, 10,000,000 rows × 30 columns.

Mental model

We build one fact table: date_key, product_sk, quantity, and 26 filler columns (30 total). We store it two ways and run one query that references only two columns:

SELECT date_key, sum(quantity) FROM facts GROUP BY date_key

The measurement that matters is bytes read — pure storage layout, no engine cleverness. The whole exercise is one script, code/column_scan.py.

Setup

cd study/ddia/ch04/code
# DuckDB via uv (installs it into a throwaway env with a compatible Python);
# sqlite3 is in the standard library. No Docker.
uv run --with duckdb python3 column_scan.py     # ~30 s: builds 10M rows, both layouts

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


Step 1 · the setup (calibration)

Predict. The table is 30 columns, 10M rows, ~1.2 GB on disk in both layouts. The query references 2 columns. Before looking: what fraction of the table's bytes does each store have to read to answer it?

How big is the same table as a row store vs a column store?
fact table: 10,000,000 rows x 30 columns query: SELECT date_key, sum(quantity) GROUP BY date_key (touches 2 of 30 columns) on-disk size of the wide table: row store (SQLite) = 1248.4 MB column store (Parquet)= 1347.6 MB

Roughly the same size on disk either way (~1.3 GB). Same data, same 30 columns. So far the two layouts look interchangeable.

Step 2 · bytes read (the surprise)

Predict. Now the real question. To compute sum(quantity) GROUP BY date_key, how many megabytes must each engine actually read off disk? (The instinct: "a database only reads what the query needs — so both read a little.")

How many MB does each store read to answer the query?
bytes the query must READ: row store scans every column of every row = 1248.4 MB (100%) column store reads only date_key + quantity = 7.56 MB (0.6% of the file) -> the column store reads 165x less data

0.6%. The row store reads all 1,248 MB — every one of the 30 columns, because they are interleaved on the pages it must scan. The column store reads 7.56 MB: just the two column chunks the query names. Same query, same data, 165× less data read, purely from how the bytes are laid out.

Step 3 · the payoff, and the free columns

Predict. 165× less data read — what does that do to wall-clock time? And: does it matter that the table has 26 columns the query never touches?

What does 165× less data do to the clock?
wall-clock, best of 3 (yours will differ; timings are not the point, bytes are): row store (SQLite) = 2585.7 ms column store (DuckDB) = 14.0 ms -> 185x faster the 26 unused columns are free in the column store: narrow (4-col) Parquet: query reads 7.56 MB, 12.7 ms wide (30-col) Parquet: query reads 7.56 MB, 14.0 ms (same bytes read; the extra columns cost nothing)

~185× faster, tracking the bytes. And the last two lines are the deeper point: a 4-column table and a 30-column table make the column store read the same 7.56 MB — the 26 extra columns are never opened. In a column store, the columns you don't query are free.


What you should see

Why

The difference is entirely row-major vs column-major layout. A row store keeps each row's columns contiguous, so the physical unit on disk — a page — holds all 30 columns of the rows it contains. To sum one column across all rows, the engine must read every page, which means pulling all 30 columns off disk even though it decodes only 2. The cost of a scan therefore scales with the width of the row: add columns and every scan gets slower, even queries that never mention them.

A column store stores each column as its own contiguous region (in Parquet, a column chunk with its own byte range). Reading date_key and quantity means reading two chunks — 7.56 MB — and the other 28 chunks are simply not fetched. The cost of a scan scales with the columns the query references, not the width of the table. Since analytics queries famously touch a handful of columns from very wide fact tables, the win is the ratio of total columns to used columns — here about 165×. (The wall-clock 185× includes DuckDB's vectorized execution on top; the 165× bytes-read figure is the pure storage effect and is what reproduces exactly.)

The boundary — an OLAP win, and an OLTP loss Column layout wins when you scan many rows but few columns. Invert it — fetch or update one whole row by key, SELECT * FROM facts WHERE id = ?, the OLTP pattern — and the row store's contiguity wins decisively: it reads one row in one place, while the column store would gather 30 separate column chunks to reassemble it. That is exactly why operational databases are row stores and warehouses are column stores; the same data wants opposite layouts for opposite workloads.

Go deeper

Sources: DDIA 2e, Ch. 4, §"Column-Oriented Storage" (Example 4-1) · Apache Parquet format docs (column chunks) · DuckDB.