Min/max skipping on sorted versus unsorted data
Measure your machineNeeds Python with PyArrow
Problem
Parquet keeps the minimum and maximum of each column for each row group, and a reader skips any group whose range cannot contain the filter value. Skipping works only if the data's order makes each group's range narrow: sorted by the column, each group covers a sliver of the domain; in random order, every group covers almost all of it. One extreme value widens its group's range, and a filter that falls inside the widened range must read the group.
Write 10 million rows with a store_id (1 to 400) into two Parquet files: one in random order, one sorted by store_id, both with 100,000 rows per row group. Then read the footer statistics and count how many row groups a filter store_id = 17 can skip. Then add one row with store_id = 0 to the sorted file's first group and observe the range widen.
The question. How many of the 100 row groups must a filter on store_id = 17 read in each file?
Predict
Expect: random order skips about 0 of 100 row groups (the ranges are about 1 to 400 each); sorted skips about 99 of 100.
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
min-max-skipping-on-sorted-versus.py # /// script # requires-python = ">=3.10" # dependencies = ["pyarrow", "numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Min/max skipping on sorted against unsorted data. 10 million rows with a store_id from 1 to 400, written to two Parquet files with 100,000 rows per row group: one in random order, one sorted by store_id. Reads the footer statistics and counts the row groups a filter store_id = 17 must read. Then adds one row with store_id = 0 to the sorted file's first row group and reads that group's range again. """ import argparse, json, os, tempfile import numpy as np, pyarrow as pa, pyarrow.parquet as pq p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--n", type=int, default=10_000_000, help="Rows.") p.add_argument("--row-group", type=int, default=100_000, help="Rows per row group.") 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() d = tempfile.mkdtemp(); rng = np.random.default_rng(args.seed) ids = rng.integers(1, 401, args.n) def must_read(path): md = pq.ParquetFile(path).metadata stats = [md.row_group(i).column(0).statistics for i in range(md.num_row_groups)] return sum(1 for s in stats if s.min <= 17 <= s.max), md.num_row_groups, (stats[0].min, stats[0].max) rows = [] for name, col in (("random order", ids), ("sorted by store_id", np.sort(ids))): path = os.path.join(d, name.split()[0] + ".parquet") pq.write_table(pa.table({"store_id": col}), path, row_group_size=args.row_group) keep, total, first = must_read(path) rows.append({"file": name, "row_groups": total, "must_read": keep, "skipped": total - keep, "first_group_range": f"{first[0]}-{first[1]}"}) col = np.sort(ids); col[0] = 0 # one outlier in the first group path = os.path.join(d, "outlier.parquet"); pq.write_table(pa.table({"store_id": col}), path, row_group_size=args.row_group) keep, total, first = must_read(path) rows.append({"file": "sorted, one row with store_id = 0", "row_groups": total, "must_read": keep, "skipped": total - keep, "first_group_range": f"{first[0]}-{first[1]}"}) for r in rows: print(f"{r['file']:<36} row groups that must be read: {r['must_read']} of {r['row_groups']} first group's range {r['first_group_range']}") for f in os.listdir(d): os.remove(os.path.join(d, f)) if args.json: with open(args.json, "w") as f: json.dump({"tables": {"files": 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 min-max-skipping-on-sorted-versus.py uv run min-max-skipping-on-sorted-versus.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --n10,000,000 | Rows. |
| --row-group100,000 | Rows per row group. |
| --seed0 | Random seed. |
Our run
Row groups a filter on store_id = 17 must read
| File | Must read | Skipped | First group's range |
|---|---|---|---|
| random order | 100 | 0 | 1-400 |
| sorted by store_id | 1 | 99 | 1-4 |
| sorted, one row with store_id = 0 | 1 | 99 | 0-4 |
random order row groups that must be read: 100 of 100 first group's range 1-400
sorted by store_id row groups that must be read: 1 of 100 first group's range 1-4
sorted, one row with store_id = 0 row groups that must be read: 1 of 100 first group's range 0-4What we found
Exactly as expected: the random file had to read 100 of 100 row groups and the sorted file 1. The outlier widened the first group's range from 1-4 to 0-4, which costs nothing for store 17; it would cost one extra group for any filter on a store whose range the outlier now overlaps, and a far outlier (a sentinel value) would overlap them all.
Read it
Statistics are a measurement of the data's order, not an index you can add. The same footer skips 99% of a sorted file and nothing of a shuffled one, which makes the sort order a performance decision taken at write time for every filter that will ever run.