Skip to content
About
Main content

Min/max skipping on sorted versus unsorted data

Measure your machineNeeds Python with PyArrow

Problem

keeps the minimum and maximum of each column for each , 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 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

In the sorted file, one row with store_id = 0 lands in the first row group. How many groups must a filter on store_id = 17 now read?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    min-max-skipping-on-sorted-versus.py47 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

Row groups a filter on store_id = 17 must read
FileMust readSkippedFirst group's range
random order10001-400
sorted by store_id1991-4
sorted, one row with store_id = 01990-4
Reference runApple M2 · 8 cores · 8 GB
Output · uv run min-max-skipping-on-sorted-versus.py
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-4
macOS 26.6.2 · Python 3.13.3 · pyarrow 26.0.0 · numpy 2.5.4 · run 2026-10-20

What 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.