Why DuckDB: 85× smaller, and why we keep no indexes

The headline number is 85×. The interesting number is where it comes from — because it is not the compression, and it is not the columnar layout alone.

The measurement

The fair way to compare two stores is the marginal cost: how much space does one more row take? A file has a fixed overhead, so measuring a 10 000-row database against a 1 000 000-row one and taking the slope removes it.

the enginethe marginal cost per page view
Umami + PostgreSQL4 750 B
Sumimi + DuckDB55.6 B

85×. In the raw files, the same 100 000 page views are 453 MB on PostgreSQL and 4.8 MB on DuckDB.

That comparison is only worth anything if the two sides hold the same data, so we checked: the same 26 fields, compared one by one with an MD5 — all identical, and the five Core Web Vitals summed to exactly the same values. Same rows, same fields, different storage.

One number, and the caliber it is measured in

There are two honest numbers and they are not the same. On synthetic data with identical IDs, the marginal cost is 85×. On real site data, with the 36-character UUIDs the server actually issues, it is about 50×. The difference is the identifier format and how much your URLs and titles repeat. So: promise 50–85×, and say which one you mean.

Where the space goes on the PostgreSQL side

Here is a real 100 000-page-view dataset, taken apart table by table:

the tablethe totalof which indexes
session_data (a key-value table, 600 000 rows)248 MB142 MB
website_event202 MB151 MB
session99 MB85 MB
the total557 MB378 MB (68%)

Two thirds of the space is indexes. And that has nothing to do with compression or columnar layout: a B-tree index entry costs 50–70 bytes whether the value in it is unique or repeated a million times. The indexes are not smaller on a row store. They are just there.

The other half of it is structural: Umami stores session attributes in a key-value table — one row per attribute per session — while Sumimi derives the session and the visitor from the event table and stores neither. The table data itself is 179 MB on PostgreSQL against 7.8 MB on DuckDB: 23× for the rows alone.

And what about the compression?

Compression is real, and it is worth measuring rather than assuming. The same 100 000 page views, at three levels of realism, in our own DuckDB file:

how realistic the data isthe size per page view
synthetic (5 000 URLs, 2 000 titles, half the columns null)36.8 B
real (50 000 long URLs, 20 000 titles, UTM filled, 33% click ids)81.4 B
worst case (100 000 unique hashes, no repetition at all)204.6 B

So "highly repetitive data compresses well" is worth 2.2–5.6× on its own. It is a real effect — and it is not where the 85× comes from. The indexes are.

The queries follow the storage

The panels are the same panels, the same data, two engines:

the panelDuckDBSQLitethe ratio
summary8.3 ms234.7 ms28×
timeseries5.1 ms97.0 ms19×
top 50 pages1.4 ms70.9 ms51×
referrers2.1 ms68.7 ms33×
browsers3.8 ms120.3 ms32×
countries3.7 ms115.3 ms31×
the file on disk6.8 MB77.8 MB11×

SQLite is not the villain here — it is the other end of the same trade-off. It is a row store with a page cache, it is a fine database, and it is not what a columnar engine is for. We ship it too, for the teams who want an ordinary database file. But for a table of 42 columns that is only ever appended to and read one column at a time, DuckDB is the right shape.

The decision we did not expect: no indexes at all

DuckDB has zonemaps — a min/max per column per row group — and they come for free with the data. They are what prunes a scan, and on our table they were enough: the query plans were identical to PostgreSQL's, and the scan times were identical too (0.005 s over 2 million rows).

But our schema had event.id BIGINT PRIMARY KEY, and DuckDB 2.0 turns that into an ART index — on disk. Measured on the real 42-column schema, 100 000 page views:

the tablethe diskthe writethe query
with a primary key+1.0 MB (+10.5 B per view)+9%the same (0.92–1.07× — noise)
without one——the same

So the index bought nothing measurable on the query side. What it did buy was a cliff:

The reason the fact table carries no index

Loading the same rows in bulk into a table with a primary key needs a ~1 GB memory budget — at 128 MB, 256 MB and 512 MB it runs out of memory, and chunking does not save it: it dies at the 13-millionth row. The same rows into a table without one need 128 MB, which is the budget the server already uses.

That is the whole argument. An index on a table that is only ever appended to buys no measurable speed, and it creates a path that only explodes on a customer's 512 MB box, only at scale. It also costs about 7% of the disk. We took the index out.

What it adds up to

Three decisions, each measured rather than assumed: a columnar engine instead of a row store, no indexes on the fact table, and one file instead of a server. Together they are why 1 000 000 page views a year take 20 GB instead of 1.73 TB — and why the panels answer in milliseconds instead of seconds.

The next post is the other measurement: how much traffic that box can actually take before anything runs out of memory.

Next: how much traffic can a 1 vCPU box take? — the memory rule, three machines and the measurement traps. Or the memory and capacity reference, if you want the tables without the story.

Sumimi / 数咪咪 is an independent open-source project. It is an open-source alternative to Umami and implements the Umami v2 HTTP API; no Umami assets are used. Licensed under the Apache-2.0 licence. Third-party names are referenced nominatively.