Skip to content

Why Analytical Queries Crawl on Postgres

Part 1 of a three-part series on what's actually under your dashboard. Next: You Might Not Need a New Database, then Why AI Analytics Needs Fast Scans.

The same query is 1 millisecond on one engine and 37 seconds on another. That is not a missing index. It's the difference between storing data in rows and storing it in columns — and it's the single most important thing to understand before you put a dashboard in front of a large table.

Postgres is a superb transactional database. It's the right default for almost every application backend, and nothing here is an argument against it for that job. But the moment you point a dashboard at it — COUNTs, GROUP BYs, and distinct counts over tens of millions of rows — it falls off a cliff that has nothing to do with configuration.

To see exactly where and why, we built a workload that looks like a real product. Arcstream is an open-source CDP (Customer Data Platform): it ingests raw behavioural events through Kafka, runs identity resolution and sessionization in Flink, and serves a live dashboard of 15 analytical queries — time-series charts, dimensional breakdowns, distinct-user counts, tenant rollups. We fed the same 134M-event dataset (90 simulated days, 5 tenants) to three engines — Apache Pinot, ClickHouse, and PostgreSQL — through the same Kafka topics on the same box, and ran the dashboard's exact SQL against each.

About the numbers. Every figure here is reproducible with arcstream — same dataset, same queries, same hardware for all engines. Runs are single-node (Docker Desktop, 24 GB RAM, 10 CPUs), warm cache, averaged over 6 runs. Treat them as a controlled comparison of storage and execution models, not production SLAs.

The evidence: query latency

Warm cache, average of 6 runs, all timings in milliseconds. Each engine uses its best available optimisation per query: Pinot reads from star-tree pre-aggregated nodes, ClickHouse from projections (falling back to a columnar scan), Postgres scans heap pages with btree filtering and parallel workers.

Events table · 134M rows

QueryPinotClickHousePostgres
Total events COUNT(*)1537,208
Active sessions (HLL distinct, recent)12133,701
Events count, 30-day filter12622,806
Events over time (7d, hourly)492226,643
Events over time (30d, daily)14313,820
Unique users over time (30d, HLL)142748,437
Top pages (30d)13114,588
Device breakdown (30d)13411,752
Country breakdown (30d)13213,005
Tenant aggregation (by event type)25323,028

The 7-day hourly query (49 ms on Pinot) filters on event_hour, which isn't a star-tree dimension, so it falls through to a columnar scan — a preview of a theme that returns in Part 3.

Now the smaller tables. At 2–2.5M rows, Postgres is fine:

Sessions · 2.5M rowsPinotClickHousePostgres
Sessions over time (30d)17223
Avg session duration1115388
Sessions count (30d)1933

Tens to low hundreds of milliseconds. The wheels only come off on the 134M-row events table. That's the tell. The problem isn't the engine's competence; it's what happens when the working set no longer fits the assumptions of row storage. The rest of this post is that mechanism, layer by layer.

How data is stored

The performance difference traces back to one design choice: how rows and columns are laid out on disk. Both layouts are correct engineering — for different workloads.

Postgres: the row-oriented heap

Postgres stores each row as a contiguous tuple on a heap page, all columns packed together. Insert an event and the whole row lands in one place. Reading a full row by primary key is a single page fetch — exactly what a transactional workload wants.

The tradeoff shows on analytical queries. SELECT device_type, COUNT(*) GROUP BY device_type needs exactly one column, but Postgres reads every page and pulls all 17 columns from each tuple to extract the one it needs. For 134M rows, that's ~39 GB of heap I/O to answer a question about a 3-value column.

Heap pages — each row stores all 17 columnsrow 1: event_id | event_type | tenant_id | event_time | canonical_id | page_url | device_type | browser | country | ...row 2: event_id | event_type | tenant_id | event_time | canonical_id | page_url | device_type | browser | country | ...row 3: event_id | event_type | tenant_id | event_time | canonical_id | page_url | device_type | browser | country | ...To read device_type, Postgres must scan all 39 GB of heap pages
Each heap page holds complete rows. A query touching one column still reads all 17.

ClickHouse / Pinot: columnar storage

Columnar engines store each column as an independent file, sorted by the table's primary key. Low-cardinality columns — like device_type with 3 distinct values — compress to a fraction of their size because identical values sit adjacent. The same GROUP BY device_type reads only the device_type file; the other 16 columns are never touched. For 134M rows, that one column compresses to ~50 MB.

Column files — each column stored and compressed independentlydevice_type: desktop, mobile, desktop, desktop, tablet, mobile, desktop, ...event_type: page_view, click, page_view, login, page_view, signup, ...event_id: a8f2c..., b3d91..., c72e4..., d19f0..., e55a1..., ...readskippedskippedOnly the device_type column is read = ~50 MB compressed
The query reads one column file and skips the rest. Compression on same-type adjacent values shrinks it further.

Column pruning: the 780× factor

A 17-column table where the query touches 1 column means ~6% of the data is read. This is the single largest factor in the whole benchmark. Postgres cannot do it, because its storage format interleaves all columns in every heap page — there is no way to read one column without reading the tuple it lives in.

Postgres: reads all 17 columns39 GB heap scanClickHouse: reads 1 column~50 MB780× less I/O

Compression

Compression works on any storage format, but columnar layout makes it dramatically more effective. Compressing a sequence of 134M same-type values finds patterns that don't exist when those values are scattered across mixed-type row tuples (UUID + string + timestamp + …).

Dictionary encoding

The device_type column has 134M values but only 3 distinct strings: "desktop", "mobile", "tablet". A columnar engine builds a dictionary mapping each to an integer (0, 1, 2) and stores the column as 134M integers instead of 134M variable-length strings. At 2 bits per value, that's ~32 MB for the raw encoded column. In Postgres, each of those 134M strings is stored inline in the heap tuple, repeated in full.

Before: 134M strings"desktop""mobile""desktop""desktop""tablet""mobile"... × 134MAfter: dictionary + integer arraydict: 0=desktop 1=mobile 2=tabletdata: 0, 1, 0, 0, 2, 1, 0, 0, 1, ...2 bits per value = ~32 MB
Dictionary encoding reduces low-cardinality columns to integer arrays. High-cardinality columns (UUIDs) fall back to general-purpose compression.

Compression effectiveness varies enormously by column cardinality. Per-column storage from the 134M-row ClickHouse events table:

CategoryExample columnsCompressedRawRatio
Low cardinality (3–90 distinct)tenant_id, event_type, device_type, country648 MB7.6 GB220×
Mid cardinalitypage_url, browser, os, referrer1.2 GB5.1 GB4–6×
High cardinality (millions unique)event_id, canonical_id, session_id6.9 GB20.2 GB1–7×

The columns dashboards actually GROUP BY — tenant, event type, device, country — compress 220×. But event_id, a UUID with 134M unique values, is 4.6 GB at 1:1 and accounts for 51% of total storage. The overall 3.85× table-level ratio blends these extremes, and that blend matters for the next layer.

The layers compound. Column pruning reduces which columns are read. Dictionary encoding shrinks each column. Block compression (LZ4/ZSTD) over a sorted column shrinks it again. A query reading 39 GB of heap pages in Postgres reads ~50 MB of compressed column data in ClickHouse. These don't add — they multiply.

Cache efficiency

With the working set compressed, more of it fits in memory. ClickHouse caches its 9 GB events dataset comfortably in 24 GB of RAM; Postgres can cache maybe 60% of the 39 GB heap. Higher hit rates mean fewer disk reads and steadier latency under concurrent load.

In practice the effective ratio is even better than the table numbers suggest. Dashboard queries predominantly hit the low-cardinality columns that compress 220×. The high-cardinality columns that dominate total storage (event_id, canonical_id) are rarely read. The columns a typical GROUP BY touches fit in a few hundred megabytes — not the 9 GB total.

Vectorised execution

Column pruning and compression reduce how much data is read. Vectorised execution changes how that data is processed once it reaches the CPU.

Postgres processes one row at a time. For each of 134M rows it calls a function to evaluate the filter, another to update the aggregate, with type checking and branching at every step. A vectorised engine (ClickHouse, DuckDB) processes a batch of 1,024–8,192 values through each operation as a single step, on contiguous arrays of same-type values — exactly what columnar storage hands it.

Row-at-a-time (Postgres)For each row: evaluate filter → check null → call aggregate → next rowrow 1row 2row 3... × 134M function calls134M iterations, branch per row, one value per instructionVectorised (ClickHouse)Load 4,096 values → filter all → aggregate all → next batch4,096 values per instruction (SIMD)next 4,096 values ...~33K batches, branchless loops, full cache-line useThis is why ClickHouse scans 134M rows in 30 ms where Postgres takes 13 seconds on the same data shape.
Row-at-a-time processes one value per function call. Vectorised execution processes thousands per instruction using SIMD and cache-friendly memory access.

Three mechanisms make it faster: SIMD instructions (AVX2/AVX-512) processing 4–16 values per CPU instruction; cache-line utilisation, where contiguous column arrays fill the full 64-byte line instead of wasting it on unrelated columns; and branchless loops, where filters compute a bitmask for the whole batch before applying it, eliminating per-row branch misprediction. ClickHouse is built around this — every operator processes column vectors. Postgres and TimescaleDB use row-at-a-time execution even on compressed columnar chunks: the data is decompressed back to rows before processing.

The memory boundary

Postgres analytical queries are fast when the table fits in the buffer cache. Our sessions table (2.5M rows, 475 MB) returns aggregations in 33–388 ms. The events table (134M rows, 39 GB) doesn't fit, and query times jump to 12–73 seconds. That's the whole story of the latency tables above, restated as a single boundary.

Columnar storage with compression pushes that boundary further out. The same 134M events occupy 9 GB in ClickHouse instead of 39 GB, so they fit in memory on hardware where the Postgres representation doesn't. Column pruning pushes it further still: a query touching 2 of 17 columns needs ~1 GB resident. And when the data outgrows one node, sharding distributes segments across machines, each with its own memory — the point where queries go from fast to slow keeps moving out.

None of this is a Postgres flaw. Row storage is the correct choice for transactional work, and every layer here — column files, dictionary encoding, vectorised execution — is a bet against the very thing that makes Postgres good at OLTP. The question is never "is Postgres slow" but "does this workload match row storage." For a dashboard over 100M+ rows, it doesn't.


Part 1 established why columnar engines win at analytics. But adopting one means operating a second database. Part 2 asks whether you can get most of the win without leaving Postgres at all: You Might Not Need a New Database.