Skip to content
About
Main content

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 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' 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

With the rows in random order, how many of the 10 files can a one-hour filter skip?

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.

    read-the-statistics-inside-a-parquet.py48 linesDownload
    View 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)
    
  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 read-the-statistics-inside-a-parquet.py
    uv run read-the-statistics-inside-a-parquet.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--files10 Files per layout.
--rows100,000 Rows per file.

Our run

A one-hour range over 10 files

A one-hour range over 10 files
LayoutSkippableMust read
sorted91
random010
Reference runApple M2 · 8 cores · 8 GB
Output · uv run read-the-statistics-inside-a-parquet.py
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: yes
macOS 26.6.2 · Python 3.13.3 · duckdb 1.5.6 · run 2026-10-20

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