Skip to content
About
Main content

Dry-run before you run

Measure your machineNeeds Python with PyArrow

Problem

On a warehouse that bills by bytes scanned, the cost of a query is known before it runs: the engine can read its metadata and add up what it would have to read. That is what a does. Columnar storage means only the referenced columns count; partition filters drop whole directories; min/max statistics drop row groups whose range cannot match the filter. But statistics only help when the filtered column is correlated with how the data is laid out. If every row group spans the full range of a value, no row group can be skipped, however selective the filter is.

On an engine with a dry-run or EXPLAIN cost estimate, run the same question four ways: SELECT * over the full table; named columns; named columns plus a partition filter; named columns plus a filter on an uncorrelated column. Record the estimated bytes each time.

The script builds a 30-day, 20-column table as date-partitioned Parquet files and computes the estimate the way a dry run does, from the Parquet metadata alone.

The question. How much do named columns, a partition filter and a filter on an uncorrelated column each cut the bytes a query would scan?


Predict

Does a filter on a column that is uncorrelated with the row order cut the bytes the two-column query would scan?

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.

    dry-run-before-you-run.py69 linesDownload
    View the code
    dry-run-before-you-run.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
    """Dry-run before you run.
    
    A dry run estimates the bytes a query will scan from metadata alone: the
    column chunks of the referenced columns, in the partitions and row groups the
    filters cannot rule out. This script builds a date-partitioned Parquet table
    and computes that estimate for the same question asked four ways.
    """
    import argparse, json, os, tempfile
    import numpy as np
    import pyarrow as pa, pyarrow.parquet as pq
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--days", type=int, default=30, help="Daily partitions.")
    p.add_argument("--rows-per-day", type=int, default=200_000, help="Rows per partition.")
    p.add_argument("--columns", type=int, default=20, help="Columns in the table.")
    p.add_argument("--row-group", type=int, default=20_000, help="Rows per Parquet row group.")
    p.add_argument("--path", default=os.path.join(tempfile.gettempdir(), "dry-run-lab"), help="Where the table is written.")
    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()
    import atexit, shutil
    atexit.register(lambda: shutil.rmtree(args.path, ignore_errors=True) if os.path.isdir(args.path) else os.path.exists(args.path) and os.remove(args.path))   # leave nothing behind
    rng = np.random.default_rng(args.seed)
    
    os.makedirs(args.path, exist_ok=True)
    names = ["order_id", "amount", "customer_score"] + [f"col{i}" for i in range(3, args.columns)]
    for d in range(args.days):
        n = args.rows_per_day
        cols = {"order_id": np.arange(d * n, (d + 1) * n), "amount": rng.gamma(2, 50, n),
                "customer_score": rng.uniform(0, 100, n)}                      # uncorrelated with everything: no clustering
        for c in names[3:]: cols[c] = rng.normal(0, 1, n)
        pq.write_table(pa.table(cols), f"{args.path}/day={d:02d}.parquet", row_group_size=args.row_group)
    
    def estimate(columns, days=None, score_range=None):
        """Bytes a dry run would charge: referenced columns, in files and row groups not ruled out."""
        total = 0
        for d in range(args.days):
            if days is not None and d not in days: continue                   # partition pruning
            md = pq.ParquetFile(f"{args.path}/day={d:02d}.parquet").metadata
            for rg in range(md.num_row_groups):
                g = md.row_group(rg)
                cols = {g.column(i).path_in_schema: g.column(i) for i in range(g.num_columns)}
                if score_range:                                                # min/max statistics
                    st = cols["customer_score"].statistics
                    if st.max < score_range[0] or st.min > score_range[1]: continue
                total += sum(cols[c].total_compressed_size for c in columns)
        return total
    
    queries = [
        ("SELECT * over the full table", estimate(names)),
        ("named columns (2)", estimate(["order_id", "amount"])),
        ("named columns + partition filter (3 days)", estimate(["order_id", "amount"], days={0, 1, 2})),
        ("named columns + filter on an uncorrelated column", estimate(["order_id", "amount", "customer_score"], score_range=(90, 100))),
    ]
    rows = [{"query": q, "estimated_mb": b / 1e6, "vs_select_star": queries[0][1] / b} for q, b in queries]
    for r in rows: print(f"{r['query']:<50} {r['estimated_mb']:9.1f} MB   ({r['vs_select_star']:5.1f}x less than SELECT *)")
    print(f"(total columns / columns used = {args.columns} / 2 = {args.columns / 2:g})")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"queries": rows, "summary": [{"cols_ratio": args.columns / 2, "named_x": rows[1]["vs_select_star"],
                       "partition_x": rows[2]["vs_select_star"], "uncorrelated_x": rows[3]["vs_select_star"]}]}}, 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 dry-run-before-you-run.py
    uv run dry-run-before-you-run.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--days30 Daily partitions.
--rows-per-day200,000 Rows per partition.
--columns20 Columns in the table.
--row-group20,000 Rows per Parquet row group.
--pathos.path.join(tempfile.gettempdir(), 'dry-run-lab') Where the table is written.
--seed0 Random seed.

Our run

Estimated bytes for the same question, four ways

Estimated bytes for the same question, four ways
QueryEstimated MBLess than SELECT *
SELECT * over the full table1,1601×
named columns (2)94.612.3×
named columns + partition filter (3 days)9.46123×
named columns + filter on an uncorrelated column1547.55×
Reference runApple M2 · 8 cores · 8 GB
Output · uv run dry-run-before-you-run.py
SELECT * over the full table                          1161.9 MB   (  1.0x less than SELECT *)
named columns (2)                                       94.6 MB   ( 12.3x less than SELECT *)
named columns + partition filter (3 days)                9.5 MB   (122.8x less than SELECT *)
named columns + filter on an uncorrelated column       153.9 MB   (  7.5x less than SELECT *)
(total columns / columns used = 20 / 2 = 10)
macOS 26.6.2 · Python 3.13.3 · pyarrow 26.0.0 · numpy 2.5.4 · run 2026-10-20

What we found

Naming two of the 20 columns cut the estimate 12.3×, close to the 10× that total ÷ used columns predicts; the columns are not all the same , so the ratio is not exact. Adding a 3-day partition filter multiplied the saving to 123×. The filter on the uncorrelated column skipped no row group at all: its estimate was 7.55× below SELECT *, lower than the plain two-column query's saving only because it reads a third column. Every row group held scores from 0 to 100, so the statistics were present and useless, as expected.


Read it

Skipping depends on correlation, not on statistics existing. A filter can only skip what the layout has grouped together: partition by the column you filter on most, or sort or cluster within files so that each row group covers a narrow range. A selective filter on a column scattered across every file reads everything.

A dry run is the cheapest cost control there is: it costs nothing, it is exact for byte-billed engines, and it can be wired into CI or a query gateway to stop the expensive mistakes before they run.