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 dry run 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
Expected result: the first two differ by roughly (total columns ÷ columns used). The partition filter multiplies the saving. The uncorrelated filter saves nothing, which shows that skipping depends on correlation and not on the presence of statistics.
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
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)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
| Knob · default | What 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
| Query | Estimated MB | Less than SELECT * |
|---|---|---|
| SELECT * over the full table | 1,160 | 1× |
| named columns (2) | 94.6 | 12.3× |
| named columns + partition filter (3 days) | 9.46 | 123× |
| named columns + filter on an uncorrelated column | 154 | 7.55× |
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)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 size, 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.