Read the statistics inside a Parquet file
Measure your machineNeeds Python with DuckDB
Problem
A Parquet footer records, for each row group and column, the minimum, the maximum and the counts. Engines use them to skip files and row groups, and catalogs merge them to describe a whole table without reading data. Both uses depend on the values being tight: a footer of a randomly ordered file says only that every value lies somewhere in the whole range.
Write 10 files with sorted timestamp values and 10 files with random values, read each footer with DuckDB's parquet_metadata, run a one-hour range filter on timestamp and count how many files can be skipped from min/max alone. Then merge two files' statistics by hand (min of mins, max of maxes, sum of counts) and check it against the engine's totals.
The question. How many files can a one-hour filter skip in each layout, and does the hand-merged summary match the engine?
Predict
Expect almost all to be skippable in the sorted case and almost none in the random case. This shows that statistics help only if data layout makes the ranges tight.
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
read-the-statistics-inside-a-parquet.py # /// script # requires-python = ">=3.10" # dependencies = ["duckdb"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Read the statistics inside a Parquet file. Writes 10 Parquet files with sorted timestamp values and 10 with random ones, reads each footer with parquet_metadata, and counts the files a range filter can skip from min/max alone. Then merges two files' statistics by hand (min of mins, max of maxes, sum of counts) and checks them against the engine. """ import argparse, json, os, tempfile import duckdb p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--files", type=int, default=10, help="Files per layout.") p.add_argument("--rows", type=int, default=100_000, help="Rows per file.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() work = tempfile.mkdtemp(); c = duckdb.connect(); N = args.files * args.rows c.execute(f"CREATE TABLE ev AS SELECT TIMESTAMP '2025-01-01' + INTERVAL (i) SECOND AS ts, i AS v FROM range({N}) t(i)") for layout, order in (("sorted", "ts"), ("random", "random()")): c.execute(f"CREATE OR REPLACE TABLE l AS SELECT *, row_number() OVER (ORDER BY {order}) - 1 AS rn FROM ev") for f in range(args.files): c.execute(f"COPY (SELECT ts, v FROM l WHERE rn // {args.rows} = {f}) TO '{work}/{layout}-{f}.parquet' (FORMAT parquet)") lo, hi = "2025-01-01 10:00:00", "2025-01-01 11:00:00" # a one-hour range rows = [] for layout in ("sorted", "random"): st = c.execute(f"""SELECT file_name, min(stats_min)::TIMESTAMP, max(stats_max)::TIMESTAMP FROM parquet_metadata('{work}/{layout}-*.parquet') WHERE path_in_schema = 'ts' GROUP BY file_name""").fetchall() skip = sum(1 for _, mn, mx in st if str(mx) < lo or str(mn) > hi) rows.append({"layout": layout, "files": len(st), "skippable": skip, "must_read": len(st) - skip}) md = c.execute(f"""SELECT stats_min_value, stats_max_value, num_values FROM parquet_metadata('{work}/sorted-[01].parquet') WHERE path_in_schema = 'v'""").fetchall() merged = {"min": min(int(m[0]) for m in md), "max": max(int(m[1]) for m in md), "count": sum(m[2] for m in md)} truth = c.execute(f"SELECT min(v), max(v), count(v) FROM read_parquet('{work}/sorted-[01].parquet')").fetchone() summary = {**{f"merged_{k}": v for k, v in merged.items()}, "engine_min": truth[0], "engine_max": truth[1], "engine_count": truth[2], "merge_matches": "yes" if (merged["min"], merged["max"], merged["count"]) == tuple(truth) else "no"} for r in rows: print(f"{r['layout']:<7} {r['files']} files: {r['skippable']} skippable from min/max for a one-hour range, {r['must_read']} must be read") print(f"merged statistics of two files by hand: min {merged['min']}, max {merged['max']}, count {merged['count']}; engine: {truth}; match: {summary['merge_matches']}") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"layouts": 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 read-the-statistics-inside-a-parquet.py uv run read-the-statistics-inside-a-parquet.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --files10 | Files per layout. |
| --rows100,000 | Rows per file. |
Our run
A one-hour range over 10 files
| Layout | Skippable | Must read |
|---|---|---|
| sorted | 9 | 1 |
| random | 0 | 10 |
sorted 10 files: 9 skippable from min/max for a one-hour range, 1 must be read
random 10 files: 0 skippable from min/max for a one-hour range, 10 must be read
merged statistics of two files by hand: min 0, max 199999, count 200000; engine: (0, 199999, 200000); match: yesWhat we found
Sorted, 9 of 10 files were skippable; random, 0. The hand-merged statistics of two files (min 0, max 200K, count 200K) matched the engine's answer: yes.
Read it
Statistics are cheap to merge because min, max and counts combine exactly, which is what lets a catalog describe a table of thousands of files from their footers. Whether they are useful is decided by the write order, not by the statistics themselves.