Skip to content

Why Columnar Storage

Code review, 11:14. Maya is reviewing 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:

for row in table:
    if row.endpoint == "/checkout":
        acc.sum += row.latency_ms
        acc.n += 1

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

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.

What happened next

The teammate was right about the columns and wrong about the bytes. Two columns out of eighty is the query; 17 GB is what came off disk, because the rows were stored as tuples and reading any column meant reading the whole row.

Columnar layout is what makes the claim true: store each column contiguously and a two-column query reads two columns, compresses them far better because neighbouring values are similar, and skips whole blocks whose min/max cannot match the filter. The query never changed.

Which is the physics under the next three pages — ClickHouse and Pinot operationalize it, and Trino is only fast when the files beneath it are columnar too. Eight seconds was never a query-planning problem.


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.


Reference

Behaviour at the next orders of magnitude, the trade-offs, the alternatives, and what to check when inheriting someone else's version of this — kept here rather than in the walkthrough above.


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:

  1. How many columns are named?
  2. How many rows survive the time filter?
  3. Is this a lookup of one entity or an aggregate of a population?
  4. 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.