Columnar Databases vs Row-Based Databases: Measured on 1 Billion Rows

Columnar databases · Row-based databases · Lab-measured at one billion rows

Columnar databases answered a one-billion-row aggregate by reading 1.03 GB. A row store would have read 80 GB.

Columnar databases store each column in its own contiguous file, so a query pays only for the columns it names. Row-based databases store whole records together, so every scan pays for every column. We measured what that single design decision means at scale: one billion rows in ClickHouse 26.7, fifty million rows in PostgreSQL 16 on the same host, and a twenty-million-row head-to-head on identical data.

1,000,000,000

Rows aggregated

3.34 s on 2 vCPUs, about 300 million rows per second

Bytes read

985 MiB of a 14.06 GiB table

Primary index

29.9 KiB in memory for the whole table

78×Fewer bytes per row read by the columnar database: 1.03 bytes versus 80.3 bytes for the PostgreSQL heap
31×Higher scan throughput per query: 300 million rows/s in ClickHouse versus 9.6 million rows/s in PostgreSQL with parallel workers
2,592 / 122,088Granules read for one month out of one billion rows: 2.1% of the table, in 205 ms
0.65 ms vs 7.3 sA single-row UPDATE on PostgreSQL versus the same change as a ClickHouse mutation on the billion-row table

Architecture

What columnar databases and row-based databases actually put on disk

Every performance difference between columnar databases and row-based databases traces back to the physical layout. The engines are not faster or slower in general; they are organised around different units of I/O.

A row store keeps each record whole. In PostgreSQL the unit is the 8 KB heap page: a page header, an array of line pointers, and tuples packed from the end of the page backwards. Each tuple carries a 23-byte header (xmin, xmax, infomask, null bitmap) followed by every column value in declaration order. In our 50-million-row lab table, eight modest columns cost 80.3 bytes per row once headers, alignment and page overhead are included, and the heap filled 489,887 pages.

Columnar databases break the same record apart. A ClickHouse MergeTree part holds one .bin file per column with that column’s values back to back, compressed in blocks with the default LZ4 codec. A marks file maps every 8,192-row granule to its byte offset, and primary.idx holds one sort-key value per granule. That sparse design is why the primary index for one billion rows measured just 29.9 KiB in memory, while the PostgreSQL B-tree on order_id measured 1.12 GB for fifty million rows, which projects to about 22.5 GB at one billion.

Columnar databases vs row-based databases: PostgreSQL heap page layout beside ClickHouse MergeTree column files with measured sizes
Figure 1. Row-based databases vs columnar databases on disk: the same 20 M-row sales table as PostgreSQL heap pages and as ClickHouse MergeTree column files. Columnar databases open only the highlighted files for an aggregate over two columns.

When a query needs two columns out of eight, or two out of two hundred, the row store still pulls whole pages through its buffer manager, because the page is the unit of I/O and the row is the unit of storage. Columnar databases open two files and leave the rest untouched. Everything measured below is a consequence of this picture.

Methodology

The lab: three tiers, two engines, one host

Comparisons of columnar databases and row-based databases usually skip the methodology, which is why their numbers cannot be checked. Every figure on this page comes from one 2-vCPU Intel Xeon host with 7 GB of RAM and a local NVMe-class virtual disk, running Ubuntu 24.04. Nothing was tuned for either engine beyond what is listed.

TierEngine and versionRowsSchemaPurpose
A · BillionClickHouse 26.7.2.1 (chDB 4.4.0), MergeTree1,000,000,0008 columns, PARTITION BY toYYYYMM(sale_date), ORDER BY (sale_date, product_id, order_id)Scale behaviour of columnar databases
B · Row store at scalePostgreSQL 16.13 heap, B-tree PK, BRIN on sale_date50,000,000Same 8 columns and distributionsPer-row cost of row-based databases, projected linearly to one billion
C · Head-to-headPostgreSQL 16.13 and ClickHouse 26.7 on identical data20,000,00012 columns including three wide text columnsLike-for-like plans, compression codecs and the write path

Table 1. Lab tiers. PostgreSQL ran with shared_buffers = 1536MB, work_mem = 64MB, track_io_timing = on; ClickHouse ran with max_threads = 2 and default settings. Warm cache, median of at least three timed runs.

Two honest caveats shape the numbers. First, the generators derive customer_id, product_id, quantity and unit_price from hash functions, so they are close to uniformly random. Real sales data has skew and repetition that compress far better, so our compression ratios are a floor for columnar databases. Second, the Tier B table was created UNLOGGED to fit the sandbox disk. That removes WAL writes but does not change the heap layout or the read path, and every read measurement comes from it; write measurements come from Tier C, which was fully logged.

Tier A results

Columnar databases at one billion rows: what we measured

One billion rows loaded in 296 seconds, about 3.4 million rows per second including data generation, then compacted to one part per month. These are the headline measurements.

MeasurementClickHouse 26.7, 1 B rowsPostgreSQL 16, 50 M rows measuredPostgreSQL projected to 1 B rows
Table size on disk14.06 GiB (27.01 GiB uncompressed)4.01 GB heap + 1.12 GB PK≈ 80.3 GB heap + ≈ 22.5 GB PK
Top-5 products by units (2 columns)3.34 s, 985 MiB read9.11 s serial · 5.21 s with 2 workers, 3.83 GB read≈ 104 s with 2 workers, ≈ 80 GB read
Revenue by region, all rows (3 columns)8.39 snot run at this tiernot projected
Revenue by region, one month205 ms, 2,592 granules3.40 s seq scan · 561 ms with BRIN≈ 11 s with BRIN (21 M rows in range)
Point lookup by order_id5 ms warm, 2,509 granules0.16 ms, 8 buffers≈ 0.2 ms (B-tree depth grows by one level at most)
Single-row update7.31 s mutation, 296 MiB part rewrittensee Tier C: 0.65 ms loggedunchanged: index lookup plus one tuple

Table 2. Projections multiply the measured 50-million-row cost by 20 and assume the heap still fits in cache, which it would not on this host. Real one-billion-row scans on a row store would be slower still, bounded by storage bandwidth.

Read the table as two stories. For queries that read many rows and few columns, columnar databases turn a problem measured in tens of gigabytes into one measured in hundreds of megabytes. For queries that touch one row, the row store wins by an order of magnitude or more, and no amount of hardware changes that. The rest of this page explains each mechanism behind those numbers.

Mechanism 1

Columnar databases read only the columns a query names

The canonical analytical query, and the one columnar databases were built for: units sold per product, top five. It touches two columns, product_id (4 bytes) and quantity (1 to 2 bytes). PostgreSQL has to read every page of the heap to extract them.

SQL · PostgreSQL 16 · 50 M rows · EXPLAIN (ANALYZE, BUFFERS)

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT product_id, SUM(quantity) AS units
FROM sales_50m
GROUP BY product_id
ORDER BY units DESC
LIMIT 5;

Limit (actual time=12769.799..12769.803 rows=5 loops=1)
  Buffers: shared hit=196197 read=293693
  -> Sort  (top-N heapsort, Memory: 25kB)
     -> HashAggregate (rows=5000, Memory Usage: 721kB)
        -> Seq Scan on sales_50m (actual time=0.050..4144.812 rows=50000000 loops=1)
              Buffers: shared hit=196194 read=293693
Execution Time: 12770.783 ms   -- median of 3 timed runs without EXPLAIN: 9.11 s serial, 5.21 s with 2 workers

SQL · ClickHouse 26.7 · 1 B rows · what the same query reads

EXPLAIN ESTIMATE
SELECT product_id, sum(quantity) FROM lab.sales_1b GROUP BY product_id;
-- parts: 48 | rows: 1,000,000,000 | marks: 122,088

SELECT column, formatReadableSize(sum(column_data_compressed_bytes)) AS on_disk
FROM system.parts_columns
WHERE database = 'lab' AND table = 'sales_1b' AND active
  AND column IN ('product_id', 'quantity')
GROUP BY column;
-- product_id  48.52 MiB
-- quantity   936.93 MiB          total read: 985 MiB for 1,000,000,000 rows

SELECT product_id, sum(quantity) AS units
FROM lab.sales_1b
GROUP BY product_id ORDER BY units DESC LIMIT 5
SETTINGS max_threads = 2;
-- median of 3 runs: 3.34 s
PostgreSQL 16, per row scanned80.3 bytes489,887 pages for 50 M rows, 9.6 M rows/s with 2 workers
ClickHouse 26.7, per row scanned1.03 bytes985 MiB for 1 B rows, 300 M rows/s on the same 2 vCPUs

Columnar databases vs row-based databases: bytes read for a one-billion-row aggregate, 80.3 GB vs 1.03 GB
Figure 2. Bytes read for the same two-column aggregate at one billion rows. ClickHouse values are measured; the PostgreSQL heap is projected linearly from the measured 50-million-row table.

The 78× byte ratio is not tuning; it is the layout. It widens with every column added to the table that the query does not use. In the Tier C head-to-head, where the table carries three wide text columns, the same query read 3.85 GB from PostgreSQL and 45.5 MiB from ClickHouse, an 85× reduction.

Production implicationTable width is free in columnar databases and expensive in row stores. A 200-column events table with a large JSON column costs a dashboard query nothing in ClickHouse unless the JSON column is selected. In PostgreSQL every aggregate pays for the row width, TOAST or not. Our ClickHouse consulting engagements often start with exactly this table shape.

Mechanism 2

Per-column compression in columnar databases, measured on one billion rows

Because every file in a column store holds one data type, the compressor sees homogeneous input and the engine can choose a codec per column. system.parts_columns reports the result column by column, something no row store can offer because it never stores a column on its own.

ColumnTypeCompressedUncompressedRatioWhy
sale_dateDate8.53 MiB1.86 GiB223.5×First sort-key column: long runs of identical values
product_idUInt3248.52 MiB3.73 GiB78.6×Second sort-key column: sorted within each day
statusLowCardinality(String)527.88 MiB956.82 MiB1.8×Dictionary-encoded, 4 distinct values
unit_priceDecimal(10,2)3.83 GiB7.45 GiB1.9×Random values, but bounded range
order_idUInt644.21 GiB7.45 GiB1.8×Third sort key: near-sequential inside each product run
regionLowCardinality(String)811.56 MiB956.82 MiB1.2×8 values, hash-random order
quantityUInt8936.93 MiB953.67 MiB1.0×Random 1 to 20, already one byte
customer_idUInt323.74 GiB3.73 GiB1.0×Random, outside the sort key: incompressible

Table 3. Per-column compression for 1,000,000,000 rows with the default LZ4 codec. Whole table: 27.01 GiB to 14.06 GiB.

The spread from 1.0× to 223× on the same table is the lesson. In columnar databases, sort order is a compression setting, not just an index. Columns early in the ORDER BY compress by two orders of magnitude; random columns outside it do not compress at all with a general-purpose codec.

Specialised codecs push further. In the Tier C head-to-head, switching from LZ4 to Delta, DoubleDelta and ZSTD(1) per column shrank the table from 1.92 GiB to 1.04 GiB and took sale_date from 215× to 804×. The same top-five query then ran at 219 ms instead of 178 ms, because ZSTD costs more CPU per byte. On a CPU-bound two-core box the smaller table lost; on storage-bound nodes reading from object storage it usually wins. Measure on your own hardware.

SQL · ClickHouse · codecs chosen per column

CREATE TABLE lab.sales_codec
(
    order_id    UInt64         CODEC(Delta(8), ZSTD(1)),
    sale_date   Date           CODEC(Delta(2), ZSTD(1)),
    customer_id UInt32         CODEC(ZSTD(1)),
    product_id  UInt32         CODEC(ZSTD(1)),
    region      LowCardinality(String),
    status      LowCardinality(String),
    quantity    UInt8          CODEC(ZSTD(1)),
    unit_price  Decimal(10, 2) CODEC(ZSTD(1))
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(sale_date)
ORDER BY (sale_date, product_id, order_id)
SETTINGS index_granularity = 8192;

Row-based databases compress too, at a different grain. PostgreSQL compresses individual oversized values through TOAST (pglz, or lz4 since PostgreSQL 14) and leaves the tuple body alone; InnoDB page compression squeezes a mixed-type 16 KB page. Neither can give sale_date its own 800× codec, because neither ever holds sale_date on its own.

Mechanism 3

Data skipping: from one billion rows to 2.1%

A monthly revenue report is the range scan every pitch for columnar databases leads with, and the fairest test in the set, because PostgreSQL has a real block-skipping structure of its own: the BRIN index, which stores a min/max summary per range of heap pages and works well on time-ordered tables.

SQL · ClickHouse 26.7 · 1 B rows · EXPLAIN indexes = 1

EXPLAIN indexes = 1
SELECT region, sum(quantity * unit_price) AS revenue
FROM lab.sales_1b
WHERE sale_date BETWEEN toDate('2024-03-01') AND toDate('2024-03-31')
GROUP BY region;

ReadFromMergeTree (lab.sales_1b)
  Parts: 1 | Granules: 2592
  Prewhere filter column: sale_date <= '2024-03-31' AND sale_date >= '2024-03-01'
  Indexes:
    Min-Max     Keys: sale_date             Parts: 1/48    Granules: 2592/122088
    Partition   Keys: toYYYYMM(sale_date)   Parts: 1/1     Granules: 2592/2592
    PrimaryKey  Keys: sale_date             Parts: 1/1     Granules: 2592/2592
                Search Algorithm: binary search
-- median of 3 runs: 205 ms
Data skipping in columnar databases: one billion rows pruned to 2,592 granules for a monthly query
Figure 3. How ClickHouse narrows a one-month predicate over one billion rows to 2,592 granules, beside what PostgreSQL read for the same predicate on 50 million rows.

Three layers of pruning fire in order, and all are cheap because columnar databases keep per-part and per-granule metadata beside the data. The min/max index discards 47 of 48 parts before any file is opened. The partition key confirms the survivor. The primary key binary-searches the marks of that part. The engine then evaluates the date predicate in PREWHERE before decompressing region, quantity and unit_price, and processes 21.2 million rows in 205 ms.

PostgreSQL skipped well too. On 50 million rows, a 408 kB BRIN index cut the scan from 489,887 pages and 3.40 s to 10,503 pages and 561 ms. Many “we need a columnar database” conversations are really “nobody created a BRIN index” conversations. The gap that remains is Mechanism 1 again: every surviving heap page still carries all eight columns, so throughput is about 1.9 million rows per second in range, against about 104 million in ClickHouse. Skipping decides how many rows; layout decides how many bytes per row.

Where skip indexes fitPredicates outside the sort-key prefix get no help from primary.idx. MergeTree skip indexes (minmax, set, bloom_filter, ngrambf_v1, tokenbf_v1) add per-granule summaries for those columns, and the 26.7 build also maintained automatic column statistics that pruned parts on order_id in the point-lookup test. Neither replaces choosing the right ORDER BY, the most consequential decision in a MergeTree schema.

Mechanism 4

Vectorised execution over columnar blocks

Contiguous arrays of one type are what a CPU wants, and columnar databases hand the executor exactly that. ClickHouse moves data as blocks of up to 65,409 rows, one array per column, and each transform loops over an array with no per-row function calls and a loop body the compiler can vectorise with SIMD instructions. That is how two vCPUs sustained about 300 million rows per second on the billion-row aggregate. Our deep dive on ClickHouse vectorized query processing measures each part of this separately.

SQL · ClickHouse 26.7 · EXPLAIN PIPELINE

EXPLAIN PIPELINE
SELECT product_id, sum(quantity) AS units
FROM lab.sales
GROUP BY product_id ORDER BY units DESC LIMIT 5
SETTINGS max_threads = 2;

(Expression)  ExpressionTransform
(Limit)       Limit
(Sorting)     MergingSortedTransform 2 → 1
                MergeSortingTransform × 2
                  PartialSortingTransform × 2
(Aggregating) Resize 2 → 2
                AggregatingTransform × 2
(Expression)      ExpressionTransform × 2
(ReadFromMergeTree) MergeTreeSelect(pool: ReadPool, algorithm: Thread) × 2 0 → 1

Two independent lanes read granules, aggregate into two hash tables and merge once at the top; more cores add more lanes. PostgreSQL’s executor is tuple-at-a-time by design: each row is fetched, deformed from its on-page representation, passed up the plan tree and hashed. JIT compilation (since PostgreSQL 11) and parallel query reduce the overhead, but the per-tuple model stays. In the 50-million-row serial plan the scan took 4.1 s of the 12.8 s total; the rest was per-tuple aggregation.

Honest limits

Where row-based databases win, measured on the same data

A comparison of columnar databases and row-based databases that only shows the columnar side winning is advertising. Point lookups and single-row changes are the OLTP staples that columnar databases handle worst, and the gap does not shrink at scale.

SQL · Point lookup on both engines

-- PostgreSQL 16, 50 M rows
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT * FROM sales_50m WHERE order_id = 31234567;
Index Scan using pk_sales_50m on sales_50m (actual time=0.068..0.068 rows=1 loops=1)
  Buffers: shared hit=4 read=4
Execution Time: 0.082 ms          -- median of 3 warm runs: 0.16 ms

-- ClickHouse 26.7, 1 B rows (order_id is third in the sort key)
EXPLAIN indexes = 1 SELECT * FROM lab.sales_1b WHERE order_id = 612345678;
  Statistics   Keys: order_id   Parts: 1/48   Granules: 2509/122088
  PrimaryKey   Keys: order_id   Granules: 2509/2509  (generic exclusion search)
-- 5 ms warm; about 20.5 M rows' worth of granules are candidates for one row

SQL · Single-row update on both engines

-- PostgreSQL 16, Tier C (logged table)
UPDATE sales SET status = 'returned' WHERE order_id = 12345678;
-- Execution Time: 0.654 ms · 19 buffers touched, 3 dirtied, one WAL record

-- ClickHouse 26.7, 1 B rows
ALTER TABLE lab.sales_1b UPDATE status = 'returned'
WHERE order_id = 612345678 SETTINGS mutations_sync = 2;
-- elapsed: 7.31 s
-- 202406_573_592_2       20,547,960 rows  295.95 MiB  active = 0  (old part)
-- 202406_573_592_2_948   20,547,960 rows  295.94 MiB  active = 1  (rewritten)
Row-based databases vs columnar databases on writes: PostgreSQL heap update path beside a ClickHouse mutation rewriting a whole part
Figure 4. Why row-based databases beat columnar databases on writes (Tier C, 20 M rows). The same one-row change: PostgreSQL writes a new tuple version and a WAL record; ClickHouse rewrites the 40 MiB part that contains the row.

Changing one row in the billion-row table rewrote a 296 MiB part of 20.5 million rows. The PostgreSQL update touched 19 buffers and is a transaction: it can be rolled back, it takes a row lock, and concurrent readers see the old version until commit. Lightweight DELETE and the newer lightweight UPDATE in ClickHouse hide part of the latency by masking rows until the next merge, but the physical work is similar and multi-statement transactions are still absent.

01

Point reads and writes

B-tree to the row, row-level locks, HOT updates, WAL. Sub-millisecond work belongs to row stores; columnar databases decompress granules to return one row.

02

Transactions

Multi-statement ACID with MVCC, foreign keys and constraints enforced at write time. MergeTree offers none of this and does not pretend to.

03

High update rates

Every UPDATE or DELETE in a column store is a part rewrite or a masked row awaiting a merge. Past a few percent of rows per day, CDC into an append-only model is the right design.

04

Small, wide SELECT *

Fetching a handful of complete rows makes a column store open every column file. A row store reads a handful of pages.

Architecture decision

Choosing between columnar databases and row-based databases by workload

The question is never whether columnar databases are faster than row-based databases. It is which access pattern dominates, and what the write path looks like.

WorkloadDecisive mechanismRow store (PostgreSQL, MySQL, SQL Server)Columnar (ClickHouse and peers)
Aggregates over billions of rows, few columnsColumn I/O and vectorisation80.3 bytes per row read; ≈104 s projected at 1 B rows with 2 workers1.03 bytes per row; 3.34 s at 1 B rows
Time-range analytics on ordered dataData skippingBRIN skips blocks; each page still carries full rows1 of 48 parts, 2.1% of granules, 205 ms
Wide tables, sparse column accessLayoutCost grows with row widthCost grows only with columns named
Storage per row at scalePer-column codecs80.3 bytes per row on this schema, before indexes15.1 bytes per row on random data; far less on real data
Point lookup by keyB-tree0.16 ms, 8 buffers5 ms, thousands of granules as candidates
Frequent single-row UPDATE or DELETEWrite path0.65 ms, transactional7.3 s, part rewrite, no rollback
Multi-statement transactions and constraintsMVCC and lockingCompleteAbsent by design in MergeTree
High-throughput inserts of new eventsAppend pathTens of thousands of rows/s per table with index maintenanceMillions of rows/s in batches; 3.4 M rows/s on 2 vCPUs here

Table 4. Decision matrix with measured values from this lab. The right architecture for most estates is both engines with a CDC or batch pipeline between them.

Keep the system of record on a row store, and move every query that reads millions of rows to produce a few hundred onto a columnar database fed from it. The mechanisms that make columnar databases fast are the same ones that make them poor at transactions, so a single engine that does both well at billion-row scale does not exist yet, whatever the HTAP slide says. Our ClickHouse migration practice designs exactly this split.

Beyond our lab

Public evidence of columnar databases at billion and trillion scale

Our lab runs on two vCPUs. Operators and researchers have published what the same architecture does on production hardware.

Cloudflare, 2018

6 million requests per second

Cloudflare documented HTTP analytics averaging 6 M requests/s with 8 M peaks, 11 M rows/s inserted and 36 nodes with 3× replication, storing each request in 36.74 bytes.

Read the engineering post

VLDB 2024

Trillions of rows, hundreds of columns

The ClickHouse paper describes an engine designed for tables with trillions of rows and evaluates it on data sets such as 3.4 billion NYC taxi rides, with SSE 4.2 through AVX-512 kernels chosen at runtime.

Read the paper

PostgreSQL docs

32 TB per table

With the default 8 KB block size a PostgreSQL table can hold up to 32 TB. At 80.3 bytes per row that is roughly 400 billion rows of this schema, all of which a full aggregate would have to read.

See PostgreSQL limits

Scope

What this test does not show

  • Row-store runs at one billion rows. The PostgreSQL numbers at that scale are linear projections from 50 million measured rows. On this host an 80 GB heap would not fit in memory, so real scans would be slower than projected, not faster.
  • Concurrency. Every number is a single query on an idle system, which favours columnar databases. A row store serving thousands of concurrent point reads is a contest the row store wins.
  • Real data skew. Hash-generated columns are a worst case for compression. Expect materially higher ratios on production data, and re-measure with system.parts_columns.
  • Other engines. Citus columnar, Parquet on object storage, SQL Server columnstore indexes and MySQL HeatWave implement the same mechanisms with different trade-offs. None changes the conclusion about the write path.
  • Cold cache. Warm-cache figures flatter the row store. From disk, a 78× byte ratio becomes a 78× I/O ratio.

Reproduce it

Generate the billion-row table yourself

Every claim about columnar databases on this page can be checked. The ClickHouse generator below loads one billion rows in ten batches; the PostgreSQL generator builds the 50-million-row comparison table.

SQL · ClickHouse 26.7 · run for batch = 0 to 9

INSERT INTO lab.sales_1b
SELECT number + 1,
       toDate('2022-01-01') + intDiv(number, 684932),
       1 + cityHash64(number) % 500000,
       1 + cityHash64(number * 7) % 5000,
       ['us-east','us-west','eu-central','eu-west','ap-south','ap-southeast','sa-east','me-central'][1 + cityHash64(number * 11) % 8],
       ['delivered','delivered','delivered','shipped','returned','cancelled'][1 + cityHash64(number * 13) % 6],
       1 + cityHash64(number * 17) % 20,
       toDecimal64((cityHash64(number * 19) % 99000 + 100) / 100, 2)
FROM numbers(batch * 100000000, 100000000)
SETTINGS max_threads = 2, max_insert_threads = 2;

OPTIMIZE TABLE lab.sales_1b FINAL;   -- one part per month before measuring

SQL · PostgreSQL 16 · 50 M-row comparison table

INSERT INTO sales_50m
SELECT g,
       DATE '2022-01-01' + (g / 34247),
       1 + (abs(hashint8(g)) % 500000),
       1 + (abs(hashint8(g * 7)) % 5000),
       (ARRAY['us-east','us-west','eu-central','eu-west','ap-south','ap-southeast','sa-east','me-central'])[1 + abs(hashint8(g * 11)) % 8],
       (ARRAY['delivered','delivered','delivered','shipped','returned','cancelled'])[1 + abs(hashint8(g * 13)) % 6],
       1 + (abs(hashint8(g * 17)) % 20),
       ((abs(hashint8(g * 19)) % 99000) + 100) / 100.0
FROM generate_series(1, 50000000) AS g;
ALTER TABLE sales_50m ADD CONSTRAINT pk_sales_50m PRIMARY KEY (order_id);
VACUUM (ANALYZE) sales_50m;

Run every query at least three times, report the median, keep max_threads and max_parallel_workers_per_gather equal, and read bytes from the catalogs (EXPLAIN BUFFERS, pg_stat_io, system.parts_columns) rather than from wall-clock time alone. Test on a copy of your own data before drawing conclusions for production, and keep a tested restore path before changing any schema that serves it.

FAQ

Columnar databases vs row-based databases: frequently asked questions

Are columnar databases always faster than row-based databases?

No. Columnar databases are faster when a query reads many rows and few columns. Row-based databases are faster for point lookups, single-row updates and transactions. In our lab the row store answered a key lookup in 0.16 ms against 5 ms, and a one-row update in 0.65 ms against 7.3 s.

How much less data does a columnar database read?

It depends on how many columns the query names relative to the table width. For a two-column aggregate over our eight-column table, ClickHouse read 1.03 bytes per row against 80.3 bytes per row for the PostgreSQL heap, a 78× difference at one billion rows.

Can PostgreSQL skip data like a column store?

Partly. A BRIN index on a time-ordered column skips blocks well: it cut our monthly query from 3.40 s to 561 ms on 50 million rows. It cannot avoid reading the other columns stored in each surviving page.

Should we replace PostgreSQL with ClickHouse?

Usually not. Keep PostgreSQL as the system of record and move large analytical queries to ClickHouse through CDC or batch loads. That split uses each engine for what our measurements show it does best.

Next step

Running analytics on billions of rows, or deciding whether to?

ChistaDATA designs, migrates, tunes and operates ClickHouse, the columnar database we specialise in, for enterprises that have outgrown row stores for analytics. We measure before we recommend, and we will tell you when a BRIN index is all you needed.

About Shiv Iyer 262 Articles
Open Source Database Systems Engineer with a deep understanding of Optimizer Internals, Performance Engineering, Scalability and Data SRE. Shiv currently is the Founder, Investor, Board Member and CEO of multiple Database Systems Infrastructure Operations companies in the Transaction Processing Computing and ColumnStores ecosystem. He is also a frequent speaker in open source software conferences globally.