Density: sparse indexes and zone maps
What does an entry per block buy, and why does a zone map prune nothing without clustering?
The constraint is that the index only has to narrow the search to a unit you are going to read in full anyway. If the read unit is a 4 MB SSTable block (a block of a sorted, immutable file in an LSM-tree database, a write-optimized design that keeps data in such files) or a 128 MB Parquet row group (a chunk of a Parquet file, a columnar file format), an entry per row wastes space in proportion to the number of rows per block.
If a block holds m entries and costs B to read, an entry per block costs 1/m the space of a dense index and adds an expected m/2 in-block comparisons. You choose m so the residual scan cost is comparable to the I/O it saved. This is the same rule that gives the natural transfer size of every storage medium: the fixed cost of an access determines the granularity of the summary above it. It is the same derivation as the disk block, the network packet size, the cache line and the micro-batch.
The precondition is the important part: a sparse index requires the data to be sorted in the same order. Without the sort order you cannot say what a block contains, so you cannot skip it. Sparse density is bought with clustering. That is why SSTables, Parquet row groups and LSM index blocks all pair a sort order with a sparse summary: not three design choices, but one design choice made three times.
Zone maps: keep the summary, drop the pointer
Zone maps are the degenerate limit of sparseness. They keep a per-block summary and drop the pointer. PostgreSQL's BRIN, Parquet footer statistics, Iceberg manifest column bounds (Iceberg is a table format over files) and ClickHouse skip indexes all store min and max per block (sometimes also a Bloom filter, a small structure that says "definitely not here" or "maybe here", a distinct count or a null count) and use them only to eliminate. With a zone map you cannot find a row. You can only prove a block cannot contain it.
The size difference is not small. A BRIN index on a correlated timestamp column stores a handful of bytes per 128-page range, while a B-tree on the same column stores an entry per row: orders of magnitude, routinely three.
Query ts BETWEEN 01:15 AND 01:30 can skip blocks 1 and 3 without reading them. If instead the events are in random order, every block has min 00:00 and max 02:59, and nothing can be skipped.
The sharpest claim of this article:
An unclustered B-tree degrades (more random fetches, worse crossover) but still finds the rows. An unclustered zone map prunes nothing: min/max over randomly distributed values covers the full domain for every block.
That single fact explains why lakehouse engines (table systems over files in object storage) spend so much effort on Z-ordering, sort keys and liquid clustering. It is not layout aesthetics. Zone maps are the only index those formats have, and clustering is the whole input to their effectiveness. If a Parquet or Iceberg query reads far more data than the predicate suggests, the question is never "is there an index?" It is "what is the correlation between the predicate column and the file layout?"
How much a zone map prunes, and what breaks it
The dependence on clustering can be made quantitative instead of folkloric. Take a block of b rows whose values are drawn uniformly at random from the normalized domain [0, 1]. The expected minimum is 1/(b + 1), the expected maximum is b/(b + 1), so the expected width of the block's [min, max] range is (b − 1)/(b + 1). For b = 10⁶ rows that is 0.999998: the block's range covers essentially the whole domain. Every block matches every predicate. Pruning is exactly zero, and the statistics were maintained perfectly the whole time.
- rows per block, values uniform on [0, 1]
Data table
| Rows per block b | Range covered |
|---|---|
| 1 | 0% |
| 1.19 | 8.61% |
| 1.41 | 17.1% |
| 1.68 | 25.3% |
| 2 | 33.2% |
| 2.37 | 40.7% |
| 2.82 | 47.6% |
| 3.35 | 54% |
| 3.98 | 59.8% |
| 4.73 | 65.1% |
| 5.62 | 69.8% |
| 6.68 | 74% |
| 7.94 | 77.6% |
| 9.44 | 80.8% |
| 11.2 | 83.6% |
| 13.3 | 86% |
| 15.8 | 88.1% |
| 18.8 | 89.9% |
| 22.4 | 91.4% |
| 26.6 | 92.8% |
| 31.6 | 93.9% |
| 37.6 | 94.8% |
| 44.7 | 95.6% |
| 53.1 | 96.3% |
| 63.1 | 96.9% |
| 75 | 97.4% |
| 89.1 | 97.8% |
| 106 | 98.1% |
| 126 | 98.4% |
| 150 | 98.7% |
| 178 | 98.9% |
| 211 | 99.1% |
| 251 | 99.2% |
| 299 | 99.3% |
| 355 | 99.4% |
| 422 | 99.5% |
| 501 | 99.6% |
| 596 | 99.7% |
| 708 | 99.7% |
| 841 | 99.8% |
| 1K | 99.8% |
| 1.19K | 99.8% |
| 1.41K | 99.9% |
| 1.68K | 99.9% |
| 2K | 99.9% |
| 2.37K | 99.9% |
| 2.82K | 99.9% |
| 3.35K | 99.9% |
| 3.98K | 99.9% |
| 4.73K | 100% |
| 5.62K | 100% |
| 6.68K | 100% |
| 7.94K | 100% |
| 9.44K | 100% |
| 11.2K | 100% |
| 13.3K | 100% |
| 15.8K | 100% |
| 18.8K | 100% |
| 22.4K | 100% |
| 26.6K | 100% |
| 31.6K | 100% |
| 37.6K | 100% |
| 44.7K | 100% |
| 53.1K | 100% |
| 63.1K | 100% |
| 75K | 100% |
| 89.1K | 100% |
| 106K | 100% |
| 126K | 100% |
| 150K | 100% |
| 178K | 100% |
| 211K | 100% |
| 251K | 100% |
| 299K | 100% |
| 355K | 100% |
| 422K | 100% |
| 501K | 100% |
| 596K | 100% |
| 708K | 100% |
| 841K | 100% |
| 1M | 100% |
Now sort the same column. Each block's range narrows to roughly 1/(number of blocks), and a point predicate hits one block. Nothing about the index changed. Only the order of the data changed. A zone map is a measurement of how well you sorted, reported at query time as an I/O bill. Snowflake's "clustering depth" is that measurement: the average number of overlapping micro-partitions at a point, which is the expected number of blocks a stabbing query touches.
Two practitioner facts cost real money:
- A single outlier destroys a block's pruning. One sentinel date,
9999-12-31for "no end date" or1900-01-01for "unknown", in an otherwise well-clustered block widens its range to the full domain and makes it match every query for the rest of its life. This is the most common cause of "we sorted the table and pruning still does not work". The aggregate is a maximum, so variance in one direction is all it takes. - Correlation is free when it is natural. An append-only table whose
order_dategrows with insertion gets pruning without anyone sorting anything, and often gets it onorder_idtoo, because the two columns are correlated. This is why BRIN indexes are very good on naturally clustered data and useless otherwise, and why "sort by one column, get pruning on three" happens: the other two were already correlated.
Min and max are only the first rung.
| Rung | Metadata per block | What it prunes |
|---|---|---|
| 1 | min and max (two values) | range and equality, if clustered |
| 2 | null count and row count | IS NULL tests, whole-block counts |
| 3 | a list of distinct values | exact equality, when cardinality is low |
| 4 | a Bloom filter | equality on high-cardinality columns, which min/max cannot touch |
| 5 | a histogram | range selectivity |
Each rung costs more metadata and prunes more, with one hard constraint the data layer does not have: the metadata must be small enough to be read entirely before the query begins. Metadata grows with file count, so past some file count planning time dominates execution time, and the metadata needs its own hierarchy (Iceberg's manifest lists). That hierarchy works because min and max are homomorphisms: they can be computed per file, rolled up into a manifest, rolled up again into a manifest list, and merged at any level without recomputation.