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 engine | the marginal cost per page view |
|---|---|
| Umami + PostgreSQL | 4 750 B |
| Sumimi + DuckDB | 55.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 table | the total | of which indexes |
|---|---|---|
session_data (a key-value table, 600 000 rows) | 248 MB | 142 MB |
website_event | 202 MB | 151 MB |
session | 99 MB | 85 MB |
| the total | 557 MB | 378 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 is | the 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 panel | DuckDB | SQLite | the ratio |
|---|---|---|---|
| summary | 8.3 ms | 234.7 ms | 28× |
| timeseries | 5.1 ms | 97.0 ms | 19× |
| top 50 pages | 1.4 ms | 70.9 ms | 51× |
| referrers | 2.1 ms | 68.7 ms | 33× |
| browsers | 3.8 ms | 120.3 ms | 32× |
| countries | 3.7 ms | 115.3 ms | 31× |
| the file on disk | 6.8 MB | 77.8 MB | 11× |
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 table | the disk | the write | the 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.