Why Columnar Storage¶
Code review, 11:14 AM. A teammate submits a query — two columns, one time filter, one GROUP BY — and argues it should be near-instant: "it only touches two columns out of eighty." In prod it takes 8 seconds and reads 17 GB.
Predict before you read on: is the two-column claim wrong, or is the disk layout the actual problem — even though the query itself is fine?
The claim about which columns matter is right and the assumption about what the engine reads off disk is wrong: a dashboard over hundreds of millions of events a day almost never wants a row, it wants two or three columns, aggregated, and the storage layout that matches that question is columns on disk, not tuples — the physics ClickHouse and Pinot operationalize, and the reason Trino is only fast when the files underneath it are columnar too.
Start with the situation¶
Observability / SaaS analytics table:
timestamp | customer_id | service | endpoint | status | latency_ms | bytes | trace_id | user_agent | ...
Twenty to eighty columns. The tile that pays the bills:
SELECT endpoint, count(*), avg(latency_ms)
FROM requests
WHERE timestamp >= now() - INTERVAL 1 HOUR
GROUP BY endpoint;
Two grouping/measure columns, a time filter. trace_id and user_agent are dead weight for this query.
Why the obvious approach breaks¶
Disk and memory buses move bytes, not “logical rows.” If a row is 800 bytes and you need 16, you still move 800 unless the engine can avoid touching the other 784.
At 5×10⁸ events/day × 800 B ≈ 400 GB/day raw. A 7-day scan of a row store is terabytes of IO for a chart with 40 endpoints on it. SSDs do not save you; they just fail later than spinning rust.
Compression does not rescue a row layout. Adjacent bytes in a row are a timestamp, a UUID, a URL, an integer, a string — high entropy. Adjacent bytes in the status column are the same three values for meters of disk.
Build the mental picture¶
Imagine a spreadsheet. A row-store page is several full rows stacked. A column-store file is one column, many rows deep.
To GROUP BY endpoint and average latency_ms you walk two columns. You never load trace_id.
flowchart TB
subgraph rows["Row pages — query still walks every field"]
P1["row0: ts, cust, ep, lat, ua, trace, ..."]
P2["row1: ts, cust, ep, lat, ua, trace, ..."]
end
subgraph cols["Column files — query opens two"]
EP["endpoint.bin ep ep ep ep ..."]
LAT["latency_ms.bin 12 40 9 800 ..."]
UA["user_agent.bin (not opened)"]
end Vectorised engines then feed those columns to the CPU as arrays. One SIMD instruction adds 8–16 latencies. A row interpreter cannot do that without gathering from tuples first.
Under the hood¶
Row vs column IO for GROUP BY two columns¶
Take 1 billion rows, 20 columns, ~50 bytes average per column → ~1 KB/row.
Row store (Postgres heap / InnoDB):
| Bytes per row touched | ~1000 (tuple + headers, even with an index-only scan you often still miss here) |
| Rows | 1×10⁹ |
| IO (uncompressed mental model) | ~1 TB |
Useful payload (endpoint + latency_ms) | ~100 GB |
| Waste | ~90% |
A B-tree on timestamp can limit the row range for “last hour,” which is why OLTP is not uniformly terrible. Once the range is “last 7 days” and you still want two columns, you are back to reading fat tuples.
Column store (Parquet / MergeTree / Pinot segment):
| Columns opened | 2 of 20 |
| Raw column bytes | ~100 GB |
| After dictionary + delta + zstd | often 5–20 GB |
| Extra skip | min/max per row group / granule if endpoint is sparse in a granule |
If ORDER BY (endpoint, timestamp) (ClickHouse) or a sorted index (Pinot), equal endpoints sit together: run-length encoding turns 50 million /health rows into a handful of runs. The same GROUP BY becomes closer to a merge of already-sorted keys.
Worked ratio for one hour at 400 million events/day ≈ 16.7 million rows/hour:
| Layout | Read (order of magnitude) |
|---|---|
| Row, 1 KB/row, last hour | ~17 GB |
| Column, 2 × 8 B averages, uncompressed | ~270 MB |
| Column, compressed status-like + ints | ~30–100 MB |
| Column + granule skip if time is the sort prefix | even less |
The dashboard latency gap (50 ms vs 8 s) is this table, not a magic join algorithm.
Compression is a columnar feature¶
Codecs that matter in production:
| Codec | Loves | Hates |
|---|---|---|
Dictionary / LowCardinality | service, country, status | trace_id, user_id |
| RLE | Sorted low-cardinality | Random high-cardinality |
| Delta / double-delta | timestamp, counters | White noise floats |
| Gorilla / FPC | Slowly changing floats | Random payloads |
| LZ4 | CPU cheap, everywhere | You needed 3× more compression |
| ZSTD | Cold storage, fatter columns | Tiny hot granules where CPU > disk |
ClickHouse lets you set codecs per column. Parquet uses dictionary + run-length + general purpose per page. Pinot similar inside segments.
Rule: sort first, compress second. ORDER BY (service, endpoint, timestamp) makes service and endpoint locally constant. ORDER BY (timestamp) makes time compressible and service a random walk — still columnar, much worse compression on dimensions.
SIMD and vectorised execution¶
CPUs offer SIMD (AVX2 / AVX-512 / NEON): one instruction, many lanes.
Row-at-a-time:
Branchy, tuple-oriented, wrecks prefetch.
Vectorised (granule / batch of 8,192 rows in ClickHouse):
load 256-bit register of endpoint dictionary ids
compare-eq → bitmask
load latencies
masked add into accumulator
This is why granule size 8,192 is not an arbitrary constant: it is large enough to amortize decoding and fill SIMD loops, small enough that skipping a granule is worth a mark lookup.
Trino pages and Spark whole-stage codegen are the same idea on data they read from Parquet. They do not get ClickHouse’s sparse primary index unless the file format + layout provides skip info.
File layout pieces you will see in EXPLAIN / parts¶
| Name | System | Role |
|---|---|---|
| Row group / stripe | Parquet / ORC | Horizontal slice with per-column min/max |
| Granule + mark | ClickHouse | 8,192 rows; mark file stores offsets into .bin |
| Segment | Pinot | Immutable columnar unit with optional inverted indexes |
| Zone map / skip index | all of the above | “this block has no customer_id = X” |
Columnar without skip metadata is still better than rows (you read fewer columns) but you cannot jump. Columnar with a sort key is the OLAP jackpot.
For the specific byte-level anatomy of a Parquet file — row groups, column chunks, pages, the footer, and how predicate pushdown and small-file pathology follow directly from that structure — see Parquet Internals.
Put it to work¶
You choose layout when you write, not when you query.
Parquet (lake, Trino/Spark):
-- Spark: write partitioned, reasonably large files, snappy/zstd
df.repartition("ds")
.write.mode("append")
.partitionBy("ds")
.option("compression", "zstd")
.parquet("s3://lake/events")
ClickHouse (serving):
CREATE TABLE requests
(
timestamp DateTime,
customer_id LowCardinality(String),
service LowCardinality(String),
endpoint LowCardinality(String),
status UInt16,
latency_ms UInt32,
bytes UInt32,
trace_id String CODEC(ZSTD(3))
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (service, endpoint, timestamp)
SETTINGS index_granularity = 8192;
Pinot (high QPS): schema + table config declaring sorted column, inverted indexes, star-tree — see Pinot.
Sanity check after load:
-- ClickHouse: compressed vs uncompressed
SELECT
table,
formatReadableSize(sum(data_compressed_bytes)) AS compressed,
formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed,
round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'requests'
GROUP BY table;
A ratio of ~2 on an analytics table usually means bad sort key or high-entropy columns dominating. A ratio of 15–30 on dimensions + timestamps is normal.
When columnar is worse¶
Columnar is not a personality. It loses when the access pattern is row-shaped.
Point lookups¶
SELECT * FROM requests WHERE trace_id = '…' needs every column of one row. Row store: one heap fetch (if indexed). Column store: open 20 files, seek to the granule that might contain the id, reconstruct the row.
If trace_id is not in the sort key, you scan (or hit a bloom skip index and still read a granule per hit). Observability “get this trace” belongs in a document/search store or a row-oriented side table, not in the dashboard OLAP table.
Wide updates¶
UPDATE customers SET plan = 'enterprise' WHERE id = 55 is a tuple rewrite (plus WAL) in Postgres. In a column store it is rewrite the part / segment (or a mutation queue that rewrites later). One column change dirties the layout because parts are immutable.
Streaming appends are fine. CDC that mutates 2% of a 10 TB table every hour is how you recreate Hadoop.
Tiny rows, transactional writes¶
Inserting one event per HTTP request: row store appends to a page. ClickHouse creates a part. Ten thousand parts later you are in too many parts. Columnar engines want batches.
Highly selective OLTP¶
WHERE customer_id = ? AND order_id = ? returning 1 row, 200 QPS, 5 ms: indexes on a row store. A sparse 8,192-row granule index will read thousands of rows of each column you project to reconstruct one tuple.
Wide SELECT * over a few rows is still OK-ish¶
The disaster is SELECT * over billions of rows. Reconstructing 10 rows from columns is fine. Reconstructing 10⁹ rows from 80 columns is how a “data export” melts the cluster.
Where teams get caught¶
JSON dumped into a String column
You kept columnar files and then stored an unstructured blob. Compression on payload String is mediocre; every query that mentions it reads the blob. Extract the dashboard dimensions at write time.
LowCardinality on a high-cardinality column
Dictionary explosion: user_id as LowCardinality(String) can use more memory than String. LowCardinality is for service, region, http_method — dozens to low tens of thousands of values, not millions.
Row groups that are tiny
100k Parquet files × 2 MB: columnar math dies in footer reads and S3 GET. Same as ClickHouse too-many-parts. Compact to ~100–500 MB files.
Predicate on uncompressed virtual columns
WHERE toString(timestamp) LIKE '2024-06-%' cannot use min/max. Filter on native types.
How it fails¶
| Symptom | Likely layout cause |
|---|---|
| Dashboard IO is terabytes for a 2-column chart | Reading a row store, or SELECT *, or JSON blobs |
| CPU pegged, disk idle | Uncompressing zstd on already-hot tiny granules; or huge dictionaries |
| Disk full despite “not that many rows” | High-entropy columns, failed merges, or you stored arrays of traces |
| Point lookup API 200 ms → 2 s after migration to CH | You migrated a KV workload onto granules |
| Mutation queue growing | Updates against MergeTree parts |
How to investigate¶
Postgres (prove the row-store tax):
EXPLAIN (ANALYZE, BUFFERS)
SELECT endpoint, avg(latency_ms)
FROM requests
WHERE timestamp > now() - interval '1 hour'
GROUP BY endpoint;
Look at shared read / read bytes. If buffers dwarf the two columns, you found it.
ClickHouse:
EXPLAIN PIPELINE
SELECT endpoint, avg(latency_ms)
FROM requests
WHERE timestamp > now() - 3600
GROUP BY endpoint;
SELECT
query,
read_rows,
formatReadableSize(read_bytes) AS read,
formatReadableSize(memory_usage) AS mem
FROM system.query_log
WHERE type = 'QueryFinish'
ORDER BY event_time DESC
LIMIT 20;
read_bytes should be in the same neighborhood as compressed size of the columns you named, times granules you did not skip. If you read 80% of the table for a 1-hour filter, timestamp is not helping the primary index — see ClickHouse ORDER BY.
Parquet / Trino: EXPLAIN should list projected columns and partition filter. Footer-heavy plans: too many files.
Marks/granules: system.parts (rows, bytes_on_disk), system.parts_columns. A part with 200 rows is a write-pattern bug, not a columnar-theory bug.
Scale 10× / 100× / 1000×¶
| Scale | Row store | Column store that matches the query |
|---|---|---|
| 10× (10⁸ → 10⁹ events/day) | Autovacuum, bloat, seq scans | Batch inserts, merges, file compaction |
| 100× | You buy a warehouse or you lose dashboards | Sort key, TTL, projections/MVs, maybe shards |
| 1000× | Not a serious option for this workload | Pre-aggregate; drop raw; object storage cold tier |
Columnar does not scale a point-lookup API 1000×. It scales scans and aggregations whose working set is a few columns.
Trade-offs¶
| Gain | Cost |
|---|---|
| 10–1000× less IO on dashboard SQL | Reconstructing a full row is extra work |
| Compression 10–30× on analytics | CPU to decode; bad codecs hurt |
| SIMD-friendly loops | Writes as immutable parts/segments |
| Skip indexes / marks | Only as good as sort / clustering |
Alternatives¶
| Technology | Layout | Use it when |
|---|---|---|
| PostgreSQL | Rows + B-trees | OLTP, point lookups, small analytics |
| ClickHouse MergeTree | Columns + sparse index | Dashboards, ad-hoc SQL, heavy scans |
| Pinot segments | Columns + inverted/star-tree | High QPS dimensional |
| Parquet on Iceberg | Columns, no serving index | Lake, Trino/Spark |
| Cassandra / Dynamo | Rows / items | KV / time-series wide rows, not GROUP BY |
| Elasticsearch | Column-ish doc values + inverted | Text + moderate aggregations |
Apply¶
Before you pick an engine, write the query and count:
- How many columns are named?
- How many rows survive the time filter?
- Is this a lookup of one entity or an aggregate of a population?
- Do you update those rows?
Aggregates over a few columns, append-only, dashboard latency → columnar. Lookups, updates, tiny writes → row store (or a hybrid: OLAP copy + OLTP source).
Then implement the copy. Do not “turn Postgres into a column store” with a dozen covering indexes; you will still update tuples.
Practice the idea¶
Use the Bloom-filter playground. Predict whether a missing value can be reported as “possibly present,” then increase the inserted-item count without increasing the bit array. Connect the rising false-positive rate to extra reads, not incorrect query results.
Check your understanding¶
Table events 80 columns, 2 billion rows. Query A: SELECT customer_id, sum(bytes) FROM events WHERE ds = '2024-06-12' GROUP BY customer_id. Query B: SELECT * FROM events WHERE request_id = 'abc'. Query C: UPDATE events SET bytes = 0 WHERE customer_id = 'free-tier'.
For each, say row store vs column store vs “neither / copy,” and what IO you expect on a cold cache.
Answer
A. Column store (ClickHouse/Pinot/Parquet). Two columns + a partition/day prune. IO: compressed customer_id + bytes for one day — GBs or less, not the full 80-column day. Row store reads fat tuples for every event that day.
B. Row store (or a search/trace store) with an index on request_id. Column store reconstructs 80 columns from a granule (8k rows × 80 columns of decode) if you even find the granule; without request_id in the sort key you scan. Neither OLAP engine wants to be this API.
C. Neither as a hot path. SQL UPDATE on 2 billion columnar rows is a mutation that rewrites parts. In a row store it is still a massive heap rewrite. Maintain bytes in a serving table keyed by customer, or write a new fact, or filter free-tier at query time from a dimension. Do not mutate the event log in place.