Build a pruning cascade and count rows touched
Simulate a modelNeeds Python with NumPy
Problem
Analytical engines rarely find rows with one index. They cascade cheap filters, each removing most of what the previous one let through: a zone map (min/max per file) skips files whose range cannot match, a Bloom filter per file skips files that cannot contain the key, and a sparse index inside the file narrows the read to a few granules. Each stage is only as good as the property it relies on: the zone map needs the data clustered by the filtered column.
Generate 10 million rows (tracking_id, ts), written in time order, into 100 files. For each file keep min/max of ts (zone map), a Bloom filter of tracking_id (1% false-positive rate), and a sparse index with one entry per 8,192 rows. Run 1,000 queries "all rows for tracking_id T in a given 1-day range" and count rows examined at each stage. Then shuffle the rows so that files are no longer clustered by time.
The question. How many rows does each stage of the cascade leave to examine, and what happens to the cascade when the rows are shuffled?
Predict
Expect each stage to cut the work by one to several orders of magnitude. After the shuffle, the zone map stops skipping anything.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
build-a-pruning-cascade-and-count.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Build a pruning cascade and count the work. 10 million rows (tracking_id, ts) written in time order into 100 files. Per file: a zone map on ts, a Bloom filter on tracking_id (1% false positives), and a sparse index with one entry per 8,192 rows (rows sorted by tracking_id within each file). 1,000 queries for one tracking_id in a one-day range: rows examined after each stage. Then the same with the rows shuffled. """ import argparse, json import numpy as np p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--rows", type=int, default=10_000_000, help="Rows.") p.add_argument("--files", type=int, default=100, help="Files.") p.add_argument("--ids", type=int, default=2_000_000, help="Distinct tracking ids.") p.add_argument("--days", type=int, default=100, help="Days of data.") p.add_argument("--granule", type=int, default=8192, help="Rows per sparse-index entry.") p.add_argument("--queries", type=int, default=1000, help="Queries.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rng = np.random.default_rng(args.seed) ts = np.sort(rng.uniform(0, args.days, args.rows)) tid = rng.integers(0, args.ids, args.rows) def build(order): t, k = ts[order], tid[order] files = [] for f in np.array_split(np.arange(args.rows), args.files): ft, fk = t[f], k[f] srt = np.argsort(fk, kind="stable"); ft, fk = ft[srt], fk[srt] # within a file, sort by tracking_id m = int(len(fk) * 9.6); kk = 7 bits = np.zeros(m, dtype=bool) h = [((fk * (2 * i + 1) * 0x9E3779B1) ^ (fk >> 3)) % m for i in range(kk)] for x in h: bits[x] = True files.append({"tmin": ft.min(), "tmax": ft.max(), "bloom": (bits, m, kk), "keys": fk, "sparse": fk[:: args.granule]}) return files def bloom_has(b, key): bits, m, kk = b return all(bits[((key * (2 * i + 1) * 0x9E3779B1) ^ (key >> 3)) % m] for i in range(kk)) def run(files): qs = [(int(rng.integers(0, args.ids)), float(rng.uniform(0, args.days - 1))) for _ in range(args.queries)] work = {"no pruning": 0, "zone map": 0, "zone map + Bloom": 0, "zone map + Bloom + sparse index": 0} for key, day in qs: for f in files: n = len(f["keys"]); work["no pruning"] += n if f["tmax"] < day or f["tmin"] > day + 1: continue work["zone map"] += n if not bloom_has(f["bloom"], key): continue work["zone map + Bloom"] += n g = np.searchsorted(f["sparse"], key, side="right") - 1 # the granule that can hold the key work["zone map + Bloom + sparse index"] += min(args.granule, n - max(g, 0) * args.granule) return {k: v / args.queries for k, v in work.items()} rows = [] for label, order in (("written in time order", np.arange(args.rows)), ("rows shuffled", rng.permutation(args.rows))): w = run(build(order)) for stage, v in w.items(): rows.append({"layout": label, "stage": stage, "rows_examined": v}) print(f"{label}: " + " ".join(f"{s}: {v:,.0f}" for s, v in w.items())) if args.json: with open(args.json, "w") as f: json.dump({"tables": {"stages": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run build-a-pruning-cascade-and-count.py uv run build-a-pruning-cascade-and-count.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --rows10,000,000 | Rows. |
| --files100 | Files. |
| --ids2,000,000 | Distinct tracking ids. |
| --days100 | Days of data. |
| --granule8192 | Rows per sparse-index entry. |
| --queries1000 | Queries. |
| --seed0 | Random seed. |
Our run
Data table
| Stages applied | series | Rows examined per query |
|---|---|---|
| — | written in time order | 10M |
| — | written in time order | 200K |
| — | written in time order | 12.2K |
| — | written in time order | 986 |
| — | rows shuffled | 10M |
| — | rows shuffled | 10M |
| — | rows shuffled | 571K |
| — | rows shuffled | 46.4K |
Average rows examined per query
| Layout | Stages | Rows examined |
|---|---|---|
| written in time order | no pruning | 10M |
| written in time order | zone map | 200K |
| written in time order | zone map + Bloom | 12.2K |
| written in time order | zone map + Bloom + sparse index | 986 |
| rows shuffled | no pruning | 10M |
| rows shuffled | zone map | 10M |
| rows shuffled | zone map + Bloom | 571K |
| rows shuffled | zone map + Bloom + sparse index | 46.4K |
written in time order: no pruning: 10,000,000 zone map: 199,700 zone map + Bloom: 12,200 zone map + Bloom + sparse index: 986
rows shuffled: no pruning: 10,000,000 zone map: 10,000,000 zone map + Bloom: 571,300 zone map + Bloom + sparse index: 46,424What we found
In time order, each stage cut the work by one to two orders of magnitude: from 10M rows to 200K with the zone map, 12.2K with the Bloom filter and 986 with the sparse index. Shuffled, the zone map skipped nothing (10M), and the cascade ended at 46.4K rows, many times more: the Bloom filter now passes every file that holds the key somewhere, and each file has a few rows of it.
Read it
The stages multiply, so one stage that stops working costs its whole factor. The zone map's factor came from the write order, not from the index, which is why a lake table's layout (sort or cluster on the columns queries filter by) is part of its indexing.
When a query gets slower without any code change, check whether the data stopped being clustered before suspecting the engine.